diff --git a/projects/packages/search/changelog/add-rsm-2291-experience-field-backend b/projects/packages/search/changelog/add-rsm-2291-experience-field-backend
new file mode 100644
index 000000000000..ba4bd08e86ef
--- /dev/null
+++ b/projects/packages/search/changelog/add-rsm-2291-experience-field-backend
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Search: Add backend support for the `experience` field in the search settings REST endpoint. `POST /jetpack/v4/search/settings` accepts `experience` (`embedded`, `overlay`, `inline`, or `off`) and updates the package state in lockstep. `GET /jetpack/v4/search/settings` returns the active `experience`, derived from the legacy settings for sites that have not yet saved via the new UI.
diff --git a/projects/packages/search/src/class-module-control.php b/projects/packages/search/src/class-module-control.php
index 4044765be1cc..8db94f1e9549 100644
--- a/projects/packages/search/src/class-module-control.php
+++ b/projects/packages/search/src/class-module-control.php
@@ -41,6 +41,15 @@ class Module_Control {
const JETPACK_SEARCH_MODULE_SLUG = 'search';
const SEARCH_MODULE_INSTANT_SEARCH_OPTION_KEY = 'instant_search_enabled';
const SEARCH_MODULE_SWAP_CLASSIC_TO_INLINE_OPTION_KEY = 'swap_classic_to_inline_search';
+ const SEARCH_MODULE_EXPERIENCE_OPTION_KEY = 'jetpack_search_experience';
+
+ /**
+ * Valid experience values.
+ */
+ const EXPERIENCE_OVERLAY = 'overlay';
+ const EXPERIENCE_EMBEDDED = 'embedded';
+ const EXPERIENCE_INLINE = 'inline';
+ const EXPERIENCE_OFF = 'off';
/**
* Contructor
@@ -170,6 +179,110 @@ public function update_swap_classic_to_inline_search( bool $swap_classic_to_inli
return update_option( self::SEARCH_MODULE_SWAP_CLASSIC_TO_INLINE_OPTION_KEY, $swap_classic_to_inline_search );
}
+ /**
+ * Get the active search experience.
+ *
+ * The wire format always resolves to one of the four values, but storage is narrower:
+ * `'off'` is read from the global Jetpack module-active state (not stored in this
+ * package's option), and `'inline'` is the absence of an opt-in (the option is
+ * deleted, not written as `'inline'`). Only `'embedded'` and `'overlay'` are
+ * actually written to `jetpack_search_experience`.
+ *
+ * @return string One of 'embedded', 'overlay', 'inline', 'off'.
+ */
+ public function get_experience() {
+ if ( ! $this->is_active() ) {
+ return self::EXPERIENCE_OFF;
+ }
+
+ $saved = get_option( self::SEARCH_MODULE_EXPERIENCE_OPTION_KEY, false );
+ if ( self::EXPERIENCE_EMBEDDED === $saved ) {
+ return self::EXPERIENCE_EMBEDDED;
+ }
+ if ( self::EXPERIENCE_OVERLAY === $saved ) {
+ return self::EXPERIENCE_OVERLAY;
+ }
+
+ // Legacy fallback for sites that have never saved via the new UI: a true
+ // `instant_search_enabled` boolean reads as overlay; otherwise inline.
+ if ( $this->is_instant_search_enabled() ) {
+ return self::EXPERIENCE_OVERLAY;
+ }
+
+ return self::EXPERIENCE_INLINE;
+ }
+
+ /**
+ * Update the search experience.
+ *
+ * Storage is narrower than the wire format: `'off'` only deactivates the global
+ * module (no write to the experience option), and `'inline'` deletes the
+ * experience option (the absence of an opt-in *is* inline). Only `'embedded'`
+ * and `'overlay'` write affirmative values.
+ *
+ * Legacy `module_active` / `instant_search_enabled` are kept in lockstep so
+ * unmigrated readers (Initializer, Options, sidebar registration) continue to
+ * see the right state until they're migrated to consult get_experience().
+ *
+ * @param string $experience One of 'embedded', 'overlay', 'inline', 'off'.
+ * @return bool|WP_Error WP_Error on failure; true on success for the affirmative
+ * branches; the bool from Modules::deactivate() for `'off'`
+ * (false signals the module was already inactive — a benign
+ * no-op the REST controller treats as success).
+ */
+ public function update_experience( string $experience ) {
+ $valid_values = array( self::EXPERIENCE_OVERLAY, self::EXPERIENCE_EMBEDDED, self::EXPERIENCE_INLINE, self::EXPERIENCE_OFF );
+ if ( ! in_array( $experience, $valid_values, true ) ) {
+ return new WP_Error(
+ 'invalid_experience',
+ esc_html__( 'Invalid experience value.', 'jetpack-search-pkg' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ switch ( $experience ) {
+ case self::EXPERIENCE_OFF:
+ // Off lives in the global jetpack_active_modules option, not in this
+ // package's experience option. Leave instant_search_enabled and the
+ // experience option untouched so re-enabling later restores the user's
+ // prior preference (matches legacy ModuleControl behaviour).
+ return ( new Modules() )->deactivate( self::JETPACK_SEARCH_MODULE_SLUG );
+
+ case self::EXPERIENCE_INLINE:
+ $result = $this->activate();
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+ $this->disable_instant_search();
+ // Inline is the absence of an opt-in — delete the option rather than
+ // writing 'inline'. Pre-existing sites that have never saved are
+ // already in this state, so this also normalises after a switch.
+ delete_option( self::SEARCH_MODULE_EXPERIENCE_OPTION_KEY );
+ return true;
+
+ case self::EXPERIENCE_EMBEDDED:
+ $result = $this->activate();
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+ $this->disable_instant_search();
+ update_option( self::SEARCH_MODULE_EXPERIENCE_OPTION_KEY, self::EXPERIENCE_EMBEDDED );
+ return true;
+
+ case self::EXPERIENCE_OVERLAY:
+ $result = $this->activate();
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+ $result = $this->enable_instant_search();
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+ update_option( self::SEARCH_MODULE_EXPERIENCE_OPTION_KEY, self::EXPERIENCE_OVERLAY );
+ return true;
+ }
+ }
+
/**
* Get a list of activated modules as an array of module slugs.
*
diff --git a/projects/packages/search/src/class-rest-controller.php b/projects/packages/search/src/class-rest-controller.php
index 0b5b144bb3b6..42c714bb50af 100644
--- a/projects/packages/search/src/class-rest-controller.php
+++ b/projects/packages/search/src/class-rest-controller.php
@@ -246,13 +246,27 @@ public function update_settings( $request ) {
$module_active = isset( $request_body['module_active'] ) ? (bool) $request_body['module_active'] : null;
$instant_search_enabled = isset( $request_body['instant_search_enabled'] ) ? (bool) $request_body['instant_search_enabled'] : null;
$swap_classic_to_inline_search = isset( $request_body['swap_classic_to_inline_search'] ) ? (bool) $request_body['swap_classic_to_inline_search'] : null;
+ $experience = isset( $request_body['experience'] ) && is_string( $request_body['experience'] )
+ ? sanitize_text_field( $request_body['experience'] )
+ : null;
- $error = $this->validate_search_settings( $module_active, $instant_search_enabled, $swap_classic_to_inline_search );
+ $error = $this->validate_search_settings( $module_active, $instant_search_enabled, $swap_classic_to_inline_search, $experience );
if ( is_wp_error( $error ) ) {
return $error;
}
+ // If an experience value was provided, delegate to Module_Control::update_experience(),
+ // which encapsulates the storage shape (off → module deactivate, inline → delete option,
+ // embedded/overlay → write affirmative value) and keeps the legacy booleans in lockstep.
+ if ( $experience !== null ) {
+ $result = $this->search_module->update_experience( $experience );
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+ return rest_ensure_response( $this->get_settings() );
+ }
+
// Enabling instant search should enable the module too.
if ( true === $instant_search_enabled && true !== $module_active ) {
$module_active = true;
@@ -298,11 +312,26 @@ public function update_settings( $request ) {
/**
* Validate $module_active and $instant_search_enabled. Returns an WP_Error instance if invalid.
*
- * @param boolean $module_active - Module status.
- * @param boolean $instant_search_enabled - Instant Search status.
- * @param boolean $swap_classic_to_inline_search - New inline search status.
+ * @param boolean $module_active - Module status.
+ * @param boolean $instant_search_enabled - Instant Search status.
+ * @param boolean $swap_classic_to_inline_search - New inline search status.
+ * @param string|null $experience - Experience value.
*/
- protected function validate_search_settings( $module_active, $instant_search_enabled, $swap_classic_to_inline_search ) {
+ protected function validate_search_settings( $module_active, $instant_search_enabled, $swap_classic_to_inline_search, $experience = null ) {
+ // `experience` is the canonical source of truth and writes the legacy booleans in lockstep.
+ // Reject requests that mix it with any other settings field so callers don't silently
+ // lose those fields — the `experience` branch in update_settings() early-returns and
+ // would otherwise drop them.
+ if ( $experience !== null ) {
+ if ( $module_active !== null || $instant_search_enabled !== null || $swap_classic_to_inline_search !== null ) {
+ return new WP_Error(
+ 'rest_invalid_arguments',
+ esc_html__( 'The `experience` field cannot be combined with `module_active`, `instant_search_enabled`, or `swap_classic_to_inline_search`.', 'jetpack-search-pkg' ),
+ array( 'status' => 400 )
+ );
+ }
+ return true;
+ }
if ( $module_active === null && $instant_search_enabled === null && $swap_classic_to_inline_search !== null ) {
// allow updating 'swap_classic_to_inline_search' without updating/validating other settings.
return true;
@@ -326,6 +355,7 @@ public function get_settings() {
'module_active' => $this->search_module->is_active(),
'instant_search_enabled' => $this->search_module->is_instant_search_enabled(),
'swap_classic_to_inline_search' => $this->search_module->is_swap_classic_to_inline_search(),
+ 'experience' => $this->search_module->get_experience(),
)
);
}
diff --git a/projects/packages/search/src/dashboard/class-initial-state.php b/projects/packages/search/src/dashboard/class-initial-state.php
index 0235176af93a..f0393f3fc160 100644
--- a/projects/packages/search/src/dashboard/class-initial-state.php
+++ b/projects/packages/search/src/dashboard/class-initial-state.php
@@ -92,6 +92,7 @@ public function get_initial_state() {
'jetpackSettings' => array(
'search' => $this->module_control->is_active(),
'instant_search_enabled' => $this->module_control->is_instant_search_enabled(),
+ 'experience' => $this->module_control->get_experience(),
),
'features' => array_map(
'sanitize_text_field',
diff --git a/projects/packages/search/src/dashboard/components/pages/dashboard-page.jsx b/projects/packages/search/src/dashboard/components/pages/dashboard-page.jsx
index 68a79fa80459..404c9d3d7bf8 100644
--- a/projects/packages/search/src/dashboard/components/pages/dashboard-page.jsx
+++ b/projects/packages/search/src/dashboard/components/pages/dashboard-page.jsx
@@ -170,7 +170,7 @@ export default function DashboardPage( { isLoading = false } ) {
/>
) }
- { isSearchBlocksEnabled && (
+ { isSearchBlocksEnabled ? (
@@ -178,26 +178,23 @@ export default function DashboardPage( { isLoading = false } ) {
+ ) : (
+
) }
- { /* ModuleControl renders regardless of the feature flag for now —
- until the back-end `experience` field lands (RSM-2291), the new
- FeatureSelector can't actually persist changes. Keeping the legacy
- toggles visible lets admins continue managing Search settings. */ }
-
branch', () => {
- test( 'renders FeatureSelector and ModuleControl when searchBlocksEnabled is true', () => {
+ test( 'renders FeatureSelector when searchBlocksEnabled is true', () => {
renderWith( { searchBlocksEnabled: true, jetpackSettings: settings } );
expect(
screen.getByRole( 'group', { name: /select a search experience for your visitors/i } )
).toBeInTheDocument();
- // ModuleControl renders unconditionally so admins can still change
- // settings until the back-end `experience` field lands.
- expect( screen.getByTestId( 'module-control' ) ).toBeInTheDocument();
+ expect( screen.queryByTestId( 'module-control' ) ).not.toBeInTheDocument();
} );
- test( 'renders only ModuleControl when searchBlocksEnabled is false', () => {
+ test( 'renders ModuleControl when searchBlocksEnabled is false', () => {
renderWith( { searchBlocksEnabled: false, jetpackSettings: settings } );
expect(
screen.queryByRole( 'group', { name: /select a search experience for your visitors/i } )
diff --git a/projects/packages/search/tests/php/Module_Control_Test.php b/projects/packages/search/tests/php/Module_Control_Test.php
index 32e4bc184e2f..8d927880d3ae 100644
--- a/projects/packages/search/tests/php/Module_Control_Test.php
+++ b/projects/packages/search/tests/php/Module_Control_Test.php
@@ -167,6 +167,223 @@ public function test_disable_instant_search() {
$this->assertFalse( get_option( Module_Control::SEARCH_MODULE_INSTANT_SEARCH_OPTION_KEY ) );
}
+ /**
+ * Inactive module always reads as 'off' regardless of any saved experience
+ * option — off lives in jetpack_active_modules, not in the package's option.
+ */
+ public function test_get_experience_off_when_module_inactive() {
+ delete_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY );
+ $this->assertEquals( Module_Control::EXPERIENCE_OFF, static::$search_module->get_experience() );
+
+ // Even with a stale 'embedded' value in the option, an inactive module is off.
+ update_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY, Module_Control::EXPERIENCE_EMBEDDED );
+ $this->assertEquals( Module_Control::EXPERIENCE_OFF, static::$search_module->get_experience() );
+ delete_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY );
+ }
+
+ /**
+ * Saved 'embedded' / 'overlay' values are returned when the module is active.
+ */
+ public function test_get_experience_returns_saved_value() {
+ add_filter( 'jetpack_options', array( $this, 'return_search_active_array' ), 10, 2 );
+
+ update_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY, Module_Control::EXPERIENCE_EMBEDDED );
+ $this->assertEquals( Module_Control::EXPERIENCE_EMBEDDED, static::$search_module->get_experience() );
+
+ update_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY, Module_Control::EXPERIENCE_OVERLAY );
+ $this->assertEquals( Module_Control::EXPERIENCE_OVERLAY, static::$search_module->get_experience() );
+
+ remove_filter( 'jetpack_options', array( $this, 'return_search_active_array' ) );
+ delete_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY );
+ }
+
+ /**
+ * Legacy fallback: active module + instant_search_enabled=true with no saved
+ * value resolves to 'overlay'.
+ */
+ public function test_get_experience_legacy_fallback_overlay() {
+ delete_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY );
+ add_filter( 'jetpack_options', array( $this, 'return_search_active_array' ), 10, 2 );
+ update_option( Module_Control::SEARCH_MODULE_INSTANT_SEARCH_OPTION_KEY, true );
+ $this->assertEquals( Module_Control::EXPERIENCE_OVERLAY, static::$search_module->get_experience() );
+ remove_filter( 'jetpack_options', array( $this, 'return_search_active_array' ) );
+ delete_option( Module_Control::SEARCH_MODULE_INSTANT_SEARCH_OPTION_KEY );
+ }
+
+ /**
+ * Active module + instant_search_enabled=false with no saved value resolves to
+ * 'inline' — inline is the absence of an opt-in.
+ */
+ public function test_get_experience_inline_when_no_opt_in() {
+ delete_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY );
+ add_filter( 'jetpack_options', array( $this, 'return_search_active_array' ), 10, 2 );
+ update_option( Module_Control::SEARCH_MODULE_INSTANT_SEARCH_OPTION_KEY, false );
+ $this->assertEquals( Module_Control::EXPERIENCE_INLINE, static::$search_module->get_experience() );
+ remove_filter( 'jetpack_options', array( $this, 'return_search_active_array' ) );
+ delete_option( Module_Control::SEARCH_MODULE_INSTANT_SEARCH_OPTION_KEY );
+ }
+
+ /**
+ * Overlay activates the module, enables instant search, and writes 'overlay'
+ * to the experience option.
+ */
+ public function test_update_experience_overlay() {
+ delete_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY );
+ // is_active() needs to return true during enable_instant_search().
+ add_filter( 'jetpack_options', array( $this, 'return_search_active_array' ), 10, 2 );
+ static::$search_module->update_experience( Module_Control::EXPERIENCE_OVERLAY );
+ remove_filter( 'jetpack_options', array( $this, 'return_search_active_array' ) );
+
+ $this->assertTrue( static::$search_module->is_instant_search_enabled() );
+ $this->assertEquals( Module_Control::EXPERIENCE_OVERLAY, get_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY ) );
+ delete_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY );
+ }
+
+ /**
+ * Embedded activates the module, disables instant search, and writes
+ * 'embedded' to the experience option.
+ */
+ public function test_update_experience_embedded() {
+ delete_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY );
+ add_filter( 'jetpack_options', array( $this, 'return_active_modules_array_without_search' ), 10, 2 );
+ static::$search_module->update_experience( Module_Control::EXPERIENCE_EMBEDDED );
+ $active_modules = get_option( 'jetpack_' . Module_Control::JETPACK_ACTIVE_MODULES_OPTION_KEY, array() );
+ remove_filter( 'jetpack_options', array( $this, 'return_active_modules_array_without_search' ) );
+
+ $this->assertContains( Module_Control::JETPACK_SEARCH_MODULE_SLUG, $active_modules );
+ $this->assertFalse( static::$search_module->is_instant_search_enabled() );
+ $this->assertEquals( Module_Control::EXPERIENCE_EMBEDDED, get_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY ) );
+ delete_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY );
+ }
+
+ /**
+ * Inline activates the module, disables instant search, and deletes the
+ * experience option (inline is the absence of an opt-in).
+ */
+ public function test_update_experience_inline_deletes_option() {
+ // Seed an existing 'embedded' to prove the switch to inline clears it.
+ update_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY, Module_Control::EXPERIENCE_EMBEDDED );
+ add_filter( 'jetpack_options', array( $this, 'return_active_modules_array_without_search' ), 10, 2 );
+ static::$search_module->update_experience( Module_Control::EXPERIENCE_INLINE );
+ $active_modules = get_option( 'jetpack_' . Module_Control::JETPACK_ACTIVE_MODULES_OPTION_KEY, array() );
+ remove_filter( 'jetpack_options', array( $this, 'return_active_modules_array_without_search' ) );
+
+ $this->assertContains( Module_Control::JETPACK_SEARCH_MODULE_SLUG, $active_modules );
+ $this->assertFalse( static::$search_module->is_instant_search_enabled() );
+ $this->assertFalse( get_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY, false ) );
+ }
+
+ /**
+ * Off deactivates the module and leaves the experience option and
+ * instant_search_enabled untouched, so re-enabling later restores the user's
+ * prior preference.
+ */
+ public function test_update_experience_off_preserves_other_state() {
+ // Start with module active, overlay saved, instant search on. The filter
+ // has to stay active across update_experience() so deactivate() has a
+ // real active-modules option to remove 'search' from — see test_deactivate_module
+ // for the same pattern.
+ add_filter( 'jetpack_options', array( $this, 'return_search_active_array' ), 10, 2 );
+ update_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY, Module_Control::EXPERIENCE_OVERLAY );
+ update_option( Module_Control::SEARCH_MODULE_INSTANT_SEARCH_OPTION_KEY, true );
+
+ $result = static::$search_module->update_experience( Module_Control::EXPERIENCE_OFF );
+
+ // Read the actual option (not via the filter) to prove deactivate() ran.
+ $active_modules = get_option( 'jetpack_' . Module_Control::JETPACK_ACTIVE_MODULES_OPTION_KEY, array() );
+ remove_filter( 'jetpack_options', array( $this, 'return_search_active_array' ) );
+
+ // Propagated from Modules::deactivate(): true when the module was actually removed.
+ $this->assertTrue( $result );
+ $this->assertNotContains( Module_Control::JETPACK_SEARCH_MODULE_SLUG, $active_modules );
+ // experience option preserved (still 'overlay' for later re-enable).
+ $this->assertEquals( Module_Control::EXPERIENCE_OVERLAY, get_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY ) );
+ // instant_search_enabled preserved.
+ $this->assertTrue( (bool) get_option( Module_Control::SEARCH_MODULE_INSTANT_SEARCH_OPTION_KEY ) );
+ delete_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY );
+ delete_option( Module_Control::SEARCH_MODULE_INSTANT_SEARCH_OPTION_KEY );
+ }
+
+ /**
+ * When the module is already inactive, Modules::deactivate() is a no-op and
+ * returns false. update_experience('off') propagates that bool — it's not an
+ * error, just a signal that nothing changed. The REST controller (which only
+ * branches on is_wp_error()) still treats it as success.
+ */
+ public function test_update_experience_off_when_module_already_inactive_returns_false() {
+ // Earlier tests in this class activate the search module via update_experience()
+ // and persist 'search' into the real jetpack_active_modules option. Set it to an
+ // empty array so deactivate() really is a no-op (`update_option` with the same
+ // value returns false).
+ update_option( 'jetpack_' . Module_Control::JETPACK_ACTIVE_MODULES_OPTION_KEY, array() );
+
+ $result = static::$search_module->update_experience( Module_Control::EXPERIENCE_OFF );
+
+ $this->assertFalse( $result );
+ $this->assertNotInstanceOf( \WP_Error::class, $result );
+ }
+
+ /**
+ * Invalid input returns WP_Error.
+ */
+ public function test_update_experience_invalid_value() {
+ $result = static::$search_module->update_experience( 'invalid_value' );
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'invalid_experience', $result->get_error_code() );
+ }
+
+ /**
+ * Each experience that calls activate() must propagate its WP_Error rather than
+ * fall through and write the experience option in an inconsistent state.
+ *
+ * @param string $experience One of 'inline', 'embedded', 'overlay'.
+ * @dataProvider experiences_requiring_activation
+ */
+ #[\PHPUnit\Framework\Attributes\DataProvider( 'experiences_requiring_activation' )]
+ public function test_update_experience_propagates_activate_error( $experience ) {
+ $plan = $this->createStub( Plan::class );
+ $plan->method( 'supports_search' )->willReturn( false );
+ $plan->method( 'supports_instant_search' )->willReturn( false );
+ $module = new Module_Control( $plan );
+
+ $result = $module->update_experience( $experience );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'not_supported', $result->get_error_code() );
+ // On failure, the experience option must not be written.
+ $this->assertFalse( get_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY, false ) );
+ }
+
+ /**
+ * @return array>
+ */
+ public static function experiences_requiring_activation() {
+ return array(
+ 'inline' => array( Module_Control::EXPERIENCE_INLINE ),
+ 'embedded' => array( Module_Control::EXPERIENCE_EMBEDDED ),
+ 'overlay' => array( Module_Control::EXPERIENCE_OVERLAY ),
+ );
+ }
+
+ /**
+ * Overlay propagates the WP_Error from enable_instant_search() (e.g. plan
+ * doesn't support instant search) and does not write the experience option.
+ */
+ public function test_update_experience_overlay_propagates_enable_instant_search_error() {
+ // $search_module_no_instant has supports_search=true but supports_instant_search=false,
+ // so activate() succeeds and enable_instant_search() returns 'not_supported'.
+ // Filter is on so is_active() returns true inside enable_instant_search().
+ add_filter( 'jetpack_options', array( $this, 'return_search_active_array' ), 10, 2 );
+
+ $result = static::$search_module_no_instant->update_experience( Module_Control::EXPERIENCE_OVERLAY );
+
+ remove_filter( 'jetpack_options', array( $this, 'return_search_active_array' ) );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'not_supported', $result->get_error_code() );
+ $this->assertFalse( get_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY, false ) );
+ }
+
/**
* Returns an empty array
*/
diff --git a/projects/packages/search/tests/php/REST_Controller_Test.php b/projects/packages/search/tests/php/REST_Controller_Test.php
index a95c2cbd143f..4a4c1b05ea56 100644
--- a/projects/packages/search/tests/php/REST_Controller_Test.php
+++ b/projects/packages/search/tests/php/REST_Controller_Test.php
@@ -119,13 +119,14 @@ public function test_update_search_settings_success_both_enable() {
'instant_search_enabled' => true,
'swap_classic_to_inline_search' => false,
);
+ $expected = array_merge( $new_settings, array( 'experience' => 'overlay' ) );
$request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' );
$request->set_header( 'content-type', 'application/json' );
$request->set_body( wp_json_encode( $new_settings, JSON_UNESCAPED_SLASHES ) );
$response = $this->server->dispatch( $request );
$this->assertEquals( 200, $response->get_status() );
- $this->assertEquals( $new_settings, $response->get_data() );
+ $this->assertEquals( $expected, $response->get_data() );
}
/**
@@ -170,13 +171,14 @@ public function test_update_search_settings_success_both_disable() {
'instant_search_enabled' => false,
'swap_classic_to_inline_search' => false,
);
+ $expected = array_merge( $new_settings, array( 'experience' => 'off' ) );
$request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' );
$request->set_header( 'content-type', 'application/json' );
$request->set_body( wp_json_encode( $new_settings, JSON_UNESCAPED_SLASHES ) );
$response = $this->server->dispatch( $request );
$this->assertEquals( 200, $response->get_status() );
- $this->assertEquals( $new_settings, $response->get_data() );
+ $this->assertEquals( $expected, $response->get_data() );
}
/**
@@ -191,6 +193,7 @@ public function test_update_search_settings_success_disable_module_only() {
'module_active' => false,
'instant_search_enabled' => false,
'swap_classic_to_inline_search' => false,
+ 'experience' => 'off',
);
$request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' );
@@ -213,6 +216,7 @@ public function test_update_search_settings_success_disable_instant_only() {
'module_active' => true,
'instant_search_enabled' => true,
'swap_classic_to_inline_search' => false,
+ 'experience' => 'overlay',
);
$request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' );
@@ -235,6 +239,7 @@ public function test_update_search_settings_success_enable_inline_search() {
'module_active' => false,
'instant_search_enabled' => false,
'swap_classic_to_inline_search' => true,
+ 'experience' => 'off',
);
$request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' );
@@ -257,6 +262,7 @@ public function test_update_search_settings_success_disable_inline_search() {
'module_active' => false,
'instant_search_enabled' => false,
'swap_classic_to_inline_search' => false,
+ 'experience' => 'off',
);
$request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' );
@@ -292,6 +298,7 @@ public function test_get_search_settings_success() {
$this->assertEquals( 200, $response->get_status() );
$this->assertArrayHasKey( 'module_active', $response->get_data() );
$this->assertArrayHasKey( 'instant_search_enabled', $response->get_data() );
+ $this->assertArrayHasKey( 'experience', $response->get_data() );
}
/**
@@ -332,6 +339,156 @@ public function test_get_search_results_success_admin() {
$this->assertEquals( 6, $response->get_data()['total'] );
}
+ /**
+ * Testing the `POST /jetpack/v4/search/settings` with experience=overlay.
+ */
+ public function test_update_settings_experience_overlay() {
+ wp_set_current_user( $this->admin_id );
+
+ $request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body( wp_json_encode( array( 'experience' => 'overlay' ), JSON_UNESCAPED_SLASHES ) );
+ $response = $this->server->dispatch( $request );
+ $this->assertEquals( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertTrue( $data['module_active'] );
+ $this->assertTrue( $data['instant_search_enabled'] );
+ $this->assertEquals( 'overlay', $data['experience'] );
+ }
+
+ /**
+ * Testing the `POST /jetpack/v4/search/settings` with experience=embedded.
+ */
+ public function test_update_settings_experience_embedded() {
+ wp_set_current_user( $this->admin_id );
+
+ $request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body( wp_json_encode( array( 'experience' => 'embedded' ), JSON_UNESCAPED_SLASHES ) );
+ $response = $this->server->dispatch( $request );
+ $this->assertEquals( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertTrue( $data['module_active'] );
+ $this->assertFalse( $data['instant_search_enabled'] );
+ $this->assertEquals( 'embedded', $data['experience'] );
+ }
+
+ /**
+ * Testing the `POST /jetpack/v4/search/settings` with experience=inline.
+ */
+ public function test_update_settings_experience_inline() {
+ wp_set_current_user( $this->admin_id );
+
+ $request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body( wp_json_encode( array( 'experience' => 'inline' ), JSON_UNESCAPED_SLASHES ) );
+ $response = $this->server->dispatch( $request );
+ $this->assertEquals( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertTrue( $data['module_active'] );
+ $this->assertFalse( $data['instant_search_enabled'] );
+ $this->assertEquals( 'inline', $data['experience'] );
+ }
+
+ /**
+ * Testing the `POST /jetpack/v4/search/settings` with experience=off.
+ */
+ public function test_update_settings_experience_off() {
+ wp_set_current_user( $this->admin_id );
+
+ // Pre-activate the module and enable instant search via the legacy path so we can verify
+ // that `experience=off` deactivates the module but preserves instant_search_enabled.
+ $activate_request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' );
+ $activate_request->set_header( 'content-type', 'application/json' );
+ $activate_request->set_body(
+ wp_json_encode(
+ array(
+ 'module_active' => true,
+ 'instant_search_enabled' => true,
+ ),
+ JSON_UNESCAPED_SLASHES
+ )
+ );
+ $this->server->dispatch( $activate_request );
+
+ $request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body( wp_json_encode( array( 'experience' => 'off' ), JSON_UNESCAPED_SLASHES ) );
+ $response = $this->server->dispatch( $request );
+ $this->assertEquals( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertFalse( $data['module_active'] );
+ $this->assertEquals( 'off', $data['experience'] );
+ // instant_search_enabled should be preserved (not changed to false by deactivation).
+ $this->assertTrue( $data['instant_search_enabled'] );
+ }
+
+ /**
+ * Testing the `POST /jetpack/v4/search/settings` with an invalid experience value.
+ */
+ public function test_update_settings_experience_invalid() {
+ wp_set_current_user( $this->admin_id );
+
+ $request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body( wp_json_encode( array( 'experience' => 'invalid_value' ), JSON_UNESCAPED_SLASHES ) );
+ $response = $this->server->dispatch( $request );
+ $this->assertEquals( 400, $response->get_status() );
+ }
+
+ /**
+ * Mixing `experience` with `module_active` or `instant_search_enabled` is rejected
+ * so callers don't silently drop fields. `experience` writes the legacy booleans
+ * in lockstep — there's no scenario where the caller needs both.
+ */
+ public function test_update_settings_experience_rejects_mixed_legacy_fields() {
+ wp_set_current_user( $this->admin_id );
+
+ foreach (
+ array(
+ array(
+ 'experience' => 'overlay',
+ 'module_active' => false,
+ ),
+ array(
+ 'experience' => 'embedded',
+ 'instant_search_enabled' => true,
+ ),
+ array(
+ 'experience' => 'inline',
+ 'swap_classic_to_inline_search' => true,
+ ),
+ ) as $body
+ ) {
+ $request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body( wp_json_encode( $body, JSON_UNESCAPED_SLASHES ) );
+ $response = $this->server->dispatch( $request );
+ $this->assertEquals( 400, $response->get_status() );
+ $this->assertEquals( 'rest_invalid_arguments', $response->get_data()['code'] );
+ }
+ }
+
+ /**
+ * Testing that the persisted experience is returned from `GET /jetpack/v4/search/settings`.
+ */
+ public function test_get_settings_returns_persisted_experience() {
+ wp_set_current_user( $this->admin_id );
+
+ // Save experience=embedded.
+ $request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body( wp_json_encode( array( 'experience' => 'embedded' ), JSON_UNESCAPED_SLASHES ) );
+ $this->server->dispatch( $request );
+
+ // Read back and check persisted value is returned.
+ $request = new WP_REST_Request( 'GET', '/jetpack/v4/search/settings' );
+ $request->set_header( 'content-type', 'application/json' );
+ $response = $this->server->dispatch( $request );
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertEquals( 'embedded', $response->get_data()['experience'] );
+ }
+
/**
* Testing the `POST /jetpack/v4/search/plan/activate` endpoint with no user.
*/
diff --git a/projects/packages/sync/changelog/add-rsm-2291-search-experience-option b/projects/packages/sync/changelog/add-rsm-2291-search-experience-option
new file mode 100644
index 000000000000..aa81ee636112
--- /dev/null
+++ b/projects/packages/sync/changelog/add-rsm-2291-search-experience-option
@@ -0,0 +1,4 @@
+Significance: patch
+Type: added
+
+Sync: Whitelist the new jetpack_search_experience option so it propagates to WPcom.
diff --git a/projects/packages/sync/src/modules/class-search.php b/projects/packages/sync/src/modules/class-search.php
index 112da68a27ca..0691174bc5fe 100644
--- a/projects/packages/sync/src/modules/class-search.php
+++ b/projects/packages/sync/src/modules/class-search.php
@@ -1771,6 +1771,7 @@ public function __construct() {
'jetpack_search_enable_sort',
'jetpack_search_inf_scroll',
'jetpack_search_show_powered_by',
+ 'jetpack_search_experience',
'instant_search_enabled',
); // end options.