From 588a792bbf61c2a1bd525713afa04dd5a5c1eeb6 Mon Sep 17 00:00:00 2001 From: Griffith Chen Date: Sat, 11 Jul 2026 23:19:07 +0800 Subject: [PATCH 1/6] Launchpad: remove the deprecated start-writing checklist The start-writing flow is retired end to end: Calypso no longer routes into it, and wpcom no longer reads its checklist or runs its onboarding email campaign. Remove the checklist and the branches that remained for it: the blog_launched gating, the site launch cleanup arm, and the tailored email trigger. Each falls through to the behavior every other site intent already gets, and design-first keeps its own handling untouched. The retired-intent cleanup added earlier stays. It is the only thing that still clears site_intent for sites left on the flow, and it no longer depends on the checklist existing. The endpoint test's checklist_slug fixture moves to design-first, since an unregistered slug is now rejected by the REST arg schema before the use_goals flag is consulted. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../changelog/remove-start-writing-checklist | 4 ++++ .../launchpad/launchpad-task-definitions.php | 15 +++------------ .../src/features/launchpad/launchpad.php | 13 ------------- .../WPCOM_REST_API_V2_Endpoint_Launchpad_Test.php | 2 +- 4 files changed, 8 insertions(+), 26 deletions(-) create mode 100644 projects/packages/jetpack-mu-wpcom/changelog/remove-start-writing-checklist diff --git a/projects/packages/jetpack-mu-wpcom/changelog/remove-start-writing-checklist b/projects/packages/jetpack-mu-wpcom/changelog/remove-start-writing-checklist new file mode 100644 index 000000000000..d1e41d04072f --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/remove-start-writing-checklist @@ -0,0 +1,4 @@ +Significance: minor +Type: removed + +Remove the deprecated start-writing launchpad checklist and its remaining task handling. diff --git a/projects/packages/jetpack-mu-wpcom/src/features/launchpad/launchpad-task-definitions.php b/projects/packages/jetpack-mu-wpcom/src/features/launchpad/launchpad-task-definitions.php index 59037d14c277..ab7dd04bffa8 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/launchpad/launchpad-task-definitions.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/launchpad/launchpad-task-definitions.php @@ -1833,9 +1833,9 @@ function wpcom_track_site_launch_task() { wpcom_launchpad_mark_launchpad_task_complete_if_active( 'videopress_launched' ); wpcom_launchpad_mark_launchpad_task_complete_if_active( 'blog_launched' ); - // Remove site intent for blog onboarding flows and disable launchpad. + // Remove site intent for the blog onboarding flow and disable launchpad. $site_intent = get_option( 'site_intent' ); - if ( in_array( $site_intent, array( 'start-writing', 'design-first' ), true ) ) { + if ( 'design-first' === $site_intent ) { update_option( 'site_intent', '' ); update_option( 'launchpad_screen', 'off' ); } @@ -1996,15 +1996,6 @@ function wpcom_launchpad_is_blog_launched_task_disabled() { } return true; } - if ( 'start-writing' === get_option( 'site_intent' ) ) { - if ( wpcom_is_checklist_task_complete( 'plan_completed' ) - && wpcom_is_checklist_task_complete( 'domain_upsell' ) - && wpcom_is_checklist_task_complete( 'setup_blog' ) - && wpcom_is_checklist_task_complete( 'first_post_published' ) ) { - return false; - } - return true; - } return false; } @@ -2819,7 +2810,7 @@ function wpcom_trigger_email_campaign() { return; } - if ( ! in_array( $site_intent, array( 'start-writing', 'design-first' ), true ) ) { + if ( 'design-first' !== $site_intent ) { return; } diff --git a/projects/packages/jetpack-mu-wpcom/src/features/launchpad/launchpad.php b/projects/packages/jetpack-mu-wpcom/src/features/launchpad/launchpad.php index ebab614cfade..2540caa028c3 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/launchpad/launchpad.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/launchpad/launchpad.php @@ -151,19 +151,6 @@ function wpcom_launchpad_get_task_list_definitions() { ), 'is_enabled_callback' => 'wpcom_launchpad_get_fullscreen_enabled', ), - 'start-writing' => array( - 'get_title' => function () { - return __( 'Next steps for your site', 'jetpack-mu-wpcom' ); - }, - 'task_ids' => array( - 'first_post_published', - 'setup_blog', - 'domain_upsell', - 'plan_completed', - 'blog_launched', - ), - 'is_enabled_callback' => 'wpcom_launchpad_get_fullscreen_enabled', - ), 'design-first' => array( 'get_title' => function () { return __( 'Next steps for your site', 'jetpack-mu-wpcom' ); diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-endpoints/WPCOM_REST_API_V2_Endpoint_Launchpad_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-endpoints/WPCOM_REST_API_V2_Endpoint_Launchpad_Test.php index 9705aa858578..0e044cb71d3a 100644 --- a/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-endpoints/WPCOM_REST_API_V2_Endpoint_Launchpad_Test.php +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-endpoints/WPCOM_REST_API_V2_Endpoint_Launchpad_Test.php @@ -410,7 +410,7 @@ public function test_get_tasklist_using_goals( $site_goals, $enable_checklist_fo update_option( 'site_goals', $site_goals ); $data = array( - 'checklist_slug' => 'start-writing', // This should get ignored, due to the use_goals flag. + 'checklist_slug' => 'design-first', // This should get ignored, due to the use_goals flag. 'launchpad_context' => 'customer-home', 'use_goals' => true, 'enable_checklist_for_goals' => $enable_checklist_for_goals, From f3c3e9bb556d3c18d66f0ee761c71cc7393953b8 Mon Sep 17 00:00:00 2001 From: Griffith Chen Date: Sat, 11 Jul 2026 23:31:20 +0800 Subject: [PATCH 2/6] Launchpad: keep retired flow slugs valid at the endpoint boundary Removing the checklist also drops the slug from the endpoint's checklist_slug enum, so a request that passes it explicitly gets a 400 during argument validation. Calypso still builds those requests from cached site_intent, and the full Launchpad reads that 400 as a fetch failure and redirects to /home. Keep the retired slugs in the enum, sourced from the same list the site-intent cleanup uses, so a stale request degrades to an empty checklist and the cleared state instead of erroring. Covered by a regression test asserting the explicit slug returns 200. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/features/launchpad/launchpad.php | 18 +++++++++++++++--- ...est-api-v2-endpoint-launchpad-navigator.php | 6 +++++- ...ss-wpcom-rest-api-v2-endpoint-launchpad.php | 6 +++++- ...COM_REST_API_V2_Endpoint_Launchpad_Test.php | 15 +++++++++++++++ 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/projects/packages/jetpack-mu-wpcom/src/features/launchpad/launchpad.php b/projects/packages/jetpack-mu-wpcom/src/features/launchpad/launchpad.php index 2540caa028c3..eb5308d458b6 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/launchpad/launchpad.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/launchpad/launchpad.php @@ -427,6 +427,20 @@ function wpcom_register_default_launchpad_checklists() { } add_action( 'init', 'wpcom_register_default_launchpad_checklists', 11 ); +/** + * The site intents of retired onboarding flows. + * + * These no longer have a checklist, but sites created under them still carry + * the value, and clients still pass it to the launchpad endpoint as a checklist + * slug. The endpoint keeps accepting these so such a request degrades to an + * empty checklist rather than a 400. Empty this list once no site carries one. + * + * @return string[] Retired site intents. + */ +function wpcom_launchpad_get_retired_site_intents() { + return array( 'start-writing' ); +} + /** * Clears the site intent for a retired onboarding flow. * @@ -439,9 +453,7 @@ function wpcom_register_default_launchpad_checklists() { * once no site carries one of these values. */ function wpcom_launchpad_clear_retired_site_intents() { - $retired_site_intents = array( 'start-writing' ); - - if ( ! in_array( get_option( 'site_intent' ), $retired_site_intents, true ) ) { + if ( ! in_array( get_option( 'site_intent' ), wpcom_launchpad_get_retired_site_intents(), true ) ) { return; } diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-launchpad-navigator.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-launchpad-navigator.php index b39ce8b4b0ec..f9bd629a86eb 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-launchpad-navigator.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-launchpad-navigator.php @@ -80,7 +80,11 @@ public function validate_checklist_slug_param( $value ) { */ public function get_checklist_slug_enums() { $checklists = wpcom_launchpad_checklists()->get_all_task_lists(); - return array_keys( $checklists ); + // Retired flow slugs are still accepted so a stale client request degrades + // to an empty checklist rather than a 400. + return array_values( + array_unique( array_merge( array_keys( $checklists ), wpcom_launchpad_get_retired_site_intents() ) ) + ); } /** diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-launchpad.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-launchpad.php index 6ad250fb1849..8cf6e2811e7a 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-launchpad.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-launchpad.php @@ -119,7 +119,11 @@ public function register_routes() { */ public function get_checklist_slug_enums() { $checklists = wpcom_launchpad_checklists()->get_all_task_lists(); - return array_keys( $checklists ); + // Retired flow slugs are still accepted so a stale client request degrades + // to an empty checklist rather than a 400. + return array_values( + array_unique( array_merge( array_keys( $checklists ), wpcom_launchpad_get_retired_site_intents() ) ) + ); } /** diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-endpoints/WPCOM_REST_API_V2_Endpoint_Launchpad_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-endpoints/WPCOM_REST_API_V2_Endpoint_Launchpad_Test.php index 0e044cb71d3a..1e63ec5ce77b 100644 --- a/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-endpoints/WPCOM_REST_API_V2_Endpoint_Launchpad_Test.php +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-endpoints/WPCOM_REST_API_V2_Endpoint_Launchpad_Test.php @@ -72,6 +72,21 @@ public function test_get_data() { $this->assertFalse( $result->get_data()['is_dismissed'] ); } + /** + * A retired flow slug has no checklist, but clients still pass it explicitly + * from stale site options. It must degrade to an empty checklist, not a 400. + */ + public function test_get_data_accepts_retired_checklist_slug() { + wp_set_current_user( $this->admin_id ); + + $request = new WP_REST_Request( Requests::GET, '/wpcom/v2/launchpad' ); + $request->set_param( 'checklist_slug', 'start-writing' ); + $result = rest_do_request( $request ); + + $this->assertEquals( 200, $result->get_status() ); + $this->assertSame( array(), $result->get_data()['checklist'] ); + } + /** * Test can_access. */ From 651c1afe9b0a47b0a14016cf8368e9dee15100fa Mon Sep 17 00:00:00 2001 From: Griffith Chen Date: Sat, 11 Jul 2026 23:46:34 +0800 Subject: [PATCH 3/6] Launchpad: read a retired Navigator active checklist as none A site's wpcom_launchpad_config can still name a retired checklist as its active_checklist_slug, from before the checklist was unregistered. The getter returned it verbatim, so the Navigator reported a current checklist that no longer exists while the available list, which filters unregistered slugs, showed nothing. Validate the stored slug against the registered checklists in the getter, matching what the available list already does and what the setter already enforces. This is a read-time repair, so it also covers sites whose site_intent was cleared earlier and would be missed by an intent-gated fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/features/launchpad/launchpad.php | 11 ++++++- .../Launchpad_Retired_Site_Intents_Test.php | 33 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/projects/packages/jetpack-mu-wpcom/src/features/launchpad/launchpad.php b/projects/packages/jetpack-mu-wpcom/src/features/launchpad/launchpad.php index eb5308d458b6..3a1ec0c27da3 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/launchpad/launchpad.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/launchpad/launchpad.php @@ -1072,7 +1072,16 @@ function wpcom_launchpad_get_active_checklist() { return null; } - return $wpcom_launchpad_config['active_checklist_slug']; + $active_checklist_slug = $wpcom_launchpad_config['active_checklist_slug']; + + // A retired checklist can linger here from before it was unregistered. Treat it + // as unset, matching how the available list drops unregistered slugs and how the + // setter refuses them, so the Navigator never reports a checklist that is gone. + if ( ! array_key_exists( $active_checklist_slug, wpcom_launchpad_checklists()->get_all_task_lists() ) ) { + return null; + } + + return $active_checklist_slug; } /** diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/launchpad/Launchpad_Retired_Site_Intents_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/launchpad/Launchpad_Retired_Site_Intents_Test.php index 5285f1e4c0c8..3054190acd43 100644 --- a/projects/packages/jetpack-mu-wpcom/tests/php/features/launchpad/Launchpad_Retired_Site_Intents_Test.php +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/launchpad/Launchpad_Retired_Site_Intents_Test.php @@ -208,4 +208,37 @@ public function test_cleanup_ignores_the_intent_seen_before_the_blog_switch() { $this->assertSame( 'design-first', get_option( 'site_intent' ), 'the switched-to blog must survive' ); $this->assertSame( 'full', get_option( 'launchpad_screen' ), 'the switched-to blog must survive' ); } + + /** + * A Navigator config left pointing at a retired checklist reports no active + * checklist, rather than one that no longer exists. This can outlive the + * intent cleanup, so it must not depend on `site_intent` still matching. + */ + public function test_retired_active_checklist_slug_reads_as_none() { + wpcom_register_default_launchpad_checklists(); + // Cleared already, to prove the repair does not depend on the intent still matching. + update_option( 'site_intent', '' ); + update_option( + 'wpcom_launchpad_config', + array( + 'active_checklist_slug' => 'start-writing', + 'navigator_checklists' => array( 'start-writing', 'design-first' ), + ) + ); + + $this->assertNull( wpcom_launchpad_get_active_checklist(), 'retired active slug must read as none' ); + } + + /** + * A live active checklist is still reported. + */ + public function test_live_active_checklist_slug_is_reported() { + wpcom_register_default_launchpad_checklists(); + update_option( + 'wpcom_launchpad_config', + array( 'active_checklist_slug' => 'design-first' ) + ); + + $this->assertSame( 'design-first', wpcom_launchpad_get_active_checklist() ); + } } From 9113acaf8495fea414967a99b6786ce1e0d8faa0 Mon Sep 17 00:00:00 2001 From: Griffith Chen Date: Sun, 12 Jul 2026 09:45:18 +0800 Subject: [PATCH 4/6] Launchpad: promote the next checklist when a retired active one is removed Reading a retired active checklist as null broke removal. The Navigator removal path compared the getter against the slug being removed, so a retired active slug no longer matched, the remaining checklist was not promoted, and the stale slug was left stored with no active checklist reported. Compare against the raw stored slug during removal, and promote the most recently added remaining checklist that is still registered, skipping any other retired slugs, or clear the option. This restores the original endpoint contract, and is reachable now that the removal enum accepts retired slugs. Also guard the active-checklist getter against a non-string stored value. This code repairs persisted legacy state, so a malformed value must read as none rather than fatal on the registered-checklist lookup. Correct the setter docblock to reflect that it already accepts null to clear. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/features/launchpad/launchpad.php | 32 ++++++++++++----- .../Launchpad_Retired_Site_Intents_Test.php | 34 +++++++++++++++++++ 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/projects/packages/jetpack-mu-wpcom/src/features/launchpad/launchpad.php b/projects/packages/jetpack-mu-wpcom/src/features/launchpad/launchpad.php index 3a1ec0c27da3..129e7810427e 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/launchpad/launchpad.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/launchpad/launchpad.php @@ -1006,24 +1006,34 @@ function wpcom_launchpad_navigator_remove_checklist( $checklist_slug ) { ); } - $current_active_checklist = wpcom_launchpad_get_active_checklist(); - $checklists = $wpcom_launchpad_config['navigator_checklists']; // Find if $checklist_slug is in the checklists array. If it is, remove it. $key = array_search( $checklist_slug, $checklists, true ); if ( $key === false ) { return array( 'updated' => false, - 'new_active_checklist' => $current_active_checklist, + 'new_active_checklist' => wpcom_launchpad_get_active_checklist(), ); } unset( $checklists[ $key ] ); - $new_active_checklist = $current_active_checklist; - if ( $current_active_checklist === $checklist_slug ) { - // get last item on $checklists array, if there is one; otherwise set to null - $new_active_checklist = end( $checklists ) ? end( $checklists ) : null; + // Compare against the raw stored slug, not the getter: a retired active checklist + // reads as null there, which would otherwise hide that it was the one removed. + $stored_active_slug = $wpcom_launchpad_config['active_checklist_slug'] ?? null; + + $new_active_checklist = wpcom_launchpad_get_active_checklist(); + if ( $stored_active_slug === $checklist_slug ) { + // The active checklist was removed. Promote the most recently added one that + // is still registered, skipping any other retired slugs, or clear it. + $registered_checklists = wpcom_launchpad_checklists()->get_all_task_lists(); + $new_active_checklist = null; + foreach ( array_reverse( $checklists ) as $remaining_slug ) { + if ( is_string( $remaining_slug ) && array_key_exists( $remaining_slug, $registered_checklists ) ) { + $new_active_checklist = $remaining_slug; + break; + } + } wpcom_launchpad_set_current_active_checklist( $new_active_checklist ); } @@ -1074,6 +1084,12 @@ function wpcom_launchpad_get_active_checklist() { $active_checklist_slug = $wpcom_launchpad_config['active_checklist_slug']; + // This repairs persisted state, so it cannot assume a well-formed value: legacy + // or custom code may have stored a non-string, which would fatal below. + if ( ! is_string( $active_checklist_slug ) ) { + return null; + } + // A retired checklist can linger here from before it was unregistered. Treat it // as unset, matching how the available list drops unregistered slugs and how the // setter refuses them, so the Navigator never reports a checklist that is gone. @@ -1087,7 +1103,7 @@ function wpcom_launchpad_get_active_checklist() { /** * Helper function to set the current active checklist in the navigator context. * - * @param string $checklist_slug The slug of the launchpad task list to mark as active. + * @param string|null $checklist_slug The slug of the launchpad task list to mark as active, or null to clear it. * @return bool Whether the option update succeeded. */ function wpcom_launchpad_set_current_active_checklist( $checklist_slug ) { diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/launchpad/Launchpad_Retired_Site_Intents_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/launchpad/Launchpad_Retired_Site_Intents_Test.php index 3054190acd43..fb6fb3f4ee6b 100644 --- a/projects/packages/jetpack-mu-wpcom/tests/php/features/launchpad/Launchpad_Retired_Site_Intents_Test.php +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/launchpad/Launchpad_Retired_Site_Intents_Test.php @@ -241,4 +241,38 @@ public function test_live_active_checklist_slug_is_reported() { $this->assertSame( 'design-first', wpcom_launchpad_get_active_checklist() ); } + + /** + * Removing a retired active checklist promotes the next registered one, rather + * than leaving the retired slug stored with no active checklist reported. + */ + public function test_removing_retired_active_checklist_promotes_next_registered() { + wpcom_register_default_launchpad_checklists(); + update_option( + 'wpcom_launchpad_config', + array( + 'active_checklist_slug' => 'start-writing', + 'navigator_checklists' => array( 'start-writing', 'design-first' ), + ) + ); + + $result = wpcom_launchpad_navigator_remove_checklist( 'start-writing' ); + + $this->assertSame( 'design-first', $result['new_active_checklist'], 'the next registered checklist must be promoted' ); + $this->assertSame( 'design-first', wpcom_launchpad_get_active_checklist(), 'the promotion must be persisted' ); + } + + /** + * A non-string active checklist slug from malformed legacy state reads as none + * rather than fataling on the registered-checklist lookup. + */ + public function test_malformed_active_checklist_slug_reads_as_none() { + wpcom_register_default_launchpad_checklists(); + update_option( + 'wpcom_launchpad_config', + array( 'active_checklist_slug' => array( 'unexpected' ) ) + ); + + $this->assertNull( wpcom_launchpad_get_active_checklist() ); + } } From 1bb0f2d73d5dda3b2c2e179ee51d8021815e5fed Mon Sep 17 00:00:00 2001 From: Griffith Chen Date: Sun, 12 Jul 2026 11:20:32 +0800 Subject: [PATCH 5/6] Launchpad: type the retired-slug response and scope retired slugs to reads Two follow-ups to accepting retired checklist slugs at the endpoint. The retired-slug response returned is_enabled: null, but the client types it as a required boolean, so a strict decoder would reject the response. Cast it to a bool: an unregistered checklist is not enabled. The test now asserts the full well-typed response. The retired slugs were also reaching mutation schemas, since one enum fed both reads and writes. A dismissal for a retired slug was accepted and persisted for a checklist that no longer exists, and setting one active returned 200 with updated: false. Split the enum: reads and removal accept retired slugs, while dismissal and set-active stay registered-only and reject them at the boundary. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...st-api-v2-endpoint-launchpad-navigator.php | 19 +++++++++--- ...s-wpcom-rest-api-v2-endpoint-launchpad.php | 21 +++++++++---- .../Launchpad_Retired_Site_Intents_Test.php | 23 ++++++++++++++ ...OM_REST_API_V2_Endpoint_Launchpad_Test.php | 30 +++++++++++++++++-- 4 files changed, 82 insertions(+), 11 deletions(-) diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-launchpad-navigator.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-launchpad-navigator.php index f9bd629a86eb..4e3a670a97cd 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-launchpad-navigator.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-launchpad-navigator.php @@ -50,7 +50,7 @@ public function register_routes() { 'remove_checklist_slug' => array( 'description' => 'The slug of the checklist to remove from the active list.', 'type' => 'string', - 'enum' => $this->get_checklist_slug_enums(), + 'enum' => $this->get_readable_checklist_slug_enums(), ), ), ), @@ -80,10 +80,21 @@ public function validate_checklist_slug_param( $value ) { */ public function get_checklist_slug_enums() { $checklists = wpcom_launchpad_checklists()->get_all_task_lists(); - // Retired flow slugs are still accepted so a stale client request degrades - // to an empty checklist rather than a 400. + return array_keys( $checklists ); + } + + /** + * Returns the checklist slugs a read or removal request may reference. + * + * Retired flow slugs are included so a stale client can still read or remove + * one. Setting a retired slug as active still uses the registered slugs from + * get_checklist_slug_enums(), so it is rejected at the boundary. + * + * @return array Array of checklist slugs. + */ + public function get_readable_checklist_slug_enums() { return array_values( - array_unique( array_merge( array_keys( $checklists ), wpcom_launchpad_get_retired_site_intents() ) ) + array_unique( array_merge( $this->get_checklist_slug_enums(), wpcom_launchpad_get_retired_site_intents() ) ) ); } diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-launchpad.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-launchpad.php index 8cf6e2811e7a..bd6f171a9e8f 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-launchpad.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-launchpad.php @@ -43,7 +43,7 @@ public function register_routes() { 'checklist_slug' => array( 'description' => 'Checklist slug', 'type' => 'string', - 'enum' => $this->get_checklist_slug_enums(), + 'enum' => $this->get_readable_checklist_slug_enums(), ), 'launchpad_context' => array( 'description' => 'Screen where Launchpand instance is loaded.', @@ -119,10 +119,21 @@ public function register_routes() { */ public function get_checklist_slug_enums() { $checklists = wpcom_launchpad_checklists()->get_all_task_lists(); - // Retired flow slugs are still accepted so a stale client request degrades - // to an empty checklist rather than a 400. + return array_keys( $checklists ); + } + + /** + * Returns the checklist slugs a read request may reference. + * + * Retired flow slugs are included so a stale client request degrades to an + * empty checklist rather than a 400. Read-only: mutations use the registered + * slugs from get_checklist_slug_enums() so they cannot act on a retired slug. + * + * @return array Array of checklist slugs. + */ + public function get_readable_checklist_slug_enums() { return array_values( - array_unique( array_merge( array_keys( $checklists ), wpcom_launchpad_get_retired_site_intents() ) ) + array_unique( array_merge( $this->get_checklist_slug_enums(), wpcom_launchpad_get_retired_site_intents() ) ) ); } @@ -194,7 +205,7 @@ public function get_data( $request ) { 'launchpad_screen' => get_option( 'launchpad_screen' ), 'checklist_statuses' => get_option( 'launchpad_checklist_tasks_statuses', array() ), 'checklist' => wpcom_get_launchpad_checklist_by_checklist_slug( $checklist_slug, $launchpad_context ), - 'is_enabled' => wpcom_get_launchpad_task_list_is_enabled( $checklist_slug ), + 'is_enabled' => (bool) wpcom_get_launchpad_task_list_is_enabled( $checklist_slug ), 'is_dismissed' => wpcom_launchpad_is_task_list_dismissed( $checklist_slug ), 'is_dismissible' => wpcom_launchpad_is_task_list_dismissible( $checklist_slug ), 'title' => wpcom_get_launchpad_checklist_title_by_checklist_slug( $checklist_slug ), diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/launchpad/Launchpad_Retired_Site_Intents_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/launchpad/Launchpad_Retired_Site_Intents_Test.php index fb6fb3f4ee6b..792fa9146ba8 100644 --- a/projects/packages/jetpack-mu-wpcom/tests/php/features/launchpad/Launchpad_Retired_Site_Intents_Test.php +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/launchpad/Launchpad_Retired_Site_Intents_Test.php @@ -5,6 +5,9 @@ * @package automattic/jetpack-mu-wpcom */ +//phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.NotAbsolutePath +require_once \Automattic\Jetpack\Jetpack_Mu_Wpcom::PKG_DIR . 'src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-launchpad-navigator.php'; + use PHPUnit\Framework\Attributes\DataProvider; /** @@ -275,4 +278,24 @@ public function test_malformed_active_checklist_slug_reads_as_none() { $this->assertNull( wpcom_launchpad_get_active_checklist() ); } + + /** + * Retired slugs are accepted for reads and removal, but not for setting the + * active checklist, so a retired flow cannot be re-selected. + */ + public function test_navigator_enums_separate_reads_from_mutations() { + wpcom_register_default_launchpad_checklists(); + $navigator = new WPCOM_REST_API_V2_Endpoint_Launchpad_Navigator(); + + $registered = $navigator->get_checklist_slug_enums(); + $this->assertContains( 'design-first', $registered ); + $this->assertNotContains( 'start-writing', $registered, 'mutations must not accept a retired slug' ); + + $readable = $navigator->get_readable_checklist_slug_enums(); + $this->assertContains( 'start-writing', $readable, 'reads and removal must accept a retired slug' ); + + $this->assertFalse( $navigator->validate_checklist_slug_param( 'start-writing' ), 'setting a retired slug active must be rejected' ); + $this->assertTrue( $navigator->validate_checklist_slug_param( 'design-first' ) ); + $this->assertTrue( $navigator->validate_checklist_slug_param( null ), 'clearing the active checklist must stay allowed' ); + } } diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-endpoints/WPCOM_REST_API_V2_Endpoint_Launchpad_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-endpoints/WPCOM_REST_API_V2_Endpoint_Launchpad_Test.php index 1e63ec5ce77b..ed42feb67a73 100644 --- a/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-endpoints/WPCOM_REST_API_V2_Endpoint_Launchpad_Test.php +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-endpoints/WPCOM_REST_API_V2_Endpoint_Launchpad_Test.php @@ -74,7 +74,8 @@ public function test_get_data() { /** * A retired flow slug has no checklist, but clients still pass it explicitly - * from stale site options. It must degrade to an empty checklist, not a 400. + * from stale site options. It must degrade to a complete, well-typed empty + * response rather than a 400, so a strict client schema still decodes it. */ public function test_get_data_accepts_retired_checklist_slug() { wp_set_current_user( $this->admin_id ); @@ -82,9 +83,34 @@ public function test_get_data_accepts_retired_checklist_slug() { $request = new WP_REST_Request( Requests::GET, '/wpcom/v2/launchpad' ); $request->set_param( 'checklist_slug', 'start-writing' ); $result = rest_do_request( $request ); + $data = $result->get_data(); $this->assertEquals( 200, $result->get_status() ); - $this->assertSame( array(), $result->get_data()['checklist'] ); + $this->assertSame( array(), $data['checklist'] ); + // is_enabled is a required boolean in the client type, never null. + $this->assertFalse( $data['is_enabled'] ); + $this->assertFalse( $data['is_dismissed'] ); + $this->assertFalse( $data['is_dismissible'] ); + } + + /** + * Retired slugs are read-only compatibility. Dismissing one is a mutation and + * must be rejected, so no dismissal is persisted for a checklist that is gone. + */ + public function test_dismissing_a_retired_checklist_slug_is_rejected() { + wp_set_current_user( $this->admin_id ); + + $result = $this->call_launchpad_api( + Requests::POST, + array( + 'is_checklist_dismissed' => array( + 'slug' => 'start-writing', + 'is_dismissed' => true, + ), + ) + ); + + $this->assertEquals( 400, $result->get_status() ); } /** From eb1b92e6c66315cbec1c3fe6f72f83933a8bb01c Mon Sep 17 00:00:00 2001 From: Griffith Chen Date: Sun, 12 Jul 2026 11:36:24 +0800 Subject: [PATCH 6/6] Launchpad: document the Navigator slug validator as nullable The validator accepts null to clear the active checklist, and the route declares the parameter as null|string, but its docblock said string. Phan flags the null caller as a type mismatch. Document it as string|null. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../class-wpcom-rest-api-v2-endpoint-launchpad-navigator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-launchpad-navigator.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-launchpad-navigator.php index 4e3a670a97cd..8025ea2b8738 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-launchpad-navigator.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-launchpad-navigator.php @@ -61,7 +61,7 @@ public function register_routes() { /** * Validates that the argument sent to the active_checklist_slug parameter is a valid checklist slug or empty. * - * @param string $value The value of the active_checklist_slug parameter. + * @param string|null $value The value of the active_checklist_slug parameter. * @return bool */ public function validate_checklist_slug_param( $value ) {