Commit e9a320bca54 for woocommerce

commit e9a320bca547f1392585afb756144ff018c4b68b
Author: Raluca Stan <ralucastn@gmail.com>
Date:   Tue Aug 18 14:25:40 2026 +0200

    Auto-inject Saved for Later after every Cart block (#65977)

    * Auto-inject Saved for Later on cart templates, not only the cart page post

    The SavedForLater block-hook callback only injected after woocommerce/cart
    when the hook context was the cart page post (WP_Post). Block themes that
    hardcode the cart block directly in page-cart.html run the block-hooks pass
    with a WP_Block_Template context, so the block silently never appeared.

    Support both contexts: the cart page post (by ID) and the cart block template
    (by CartTemplate::SLUG), mirroring WC_Brands and OrderConfirmation\CreateAccount.
    The double-injection has_block guard now reads content from whichever context
    shape applies (array, template, or post).

    * Auto-inject Saved for Later after the Cart block via block.json blockHooks

    Declare the hook in block.json (blockHooks: woocommerce/cart => after) instead
    of a PHP hooked_block_types filter. The editor reads block_hooks from the
    registered block type, so it treats Saved for Later as a first-class hooked
    block and reconciles it on save, rather than recording it as ignored and
    dropping the block (which left the cart with an ignoredHookedBlocks marker and
    no rendered block). Registration stays gated by the cart_save_for_later
    feature, so the hook only applies when the feature is enabled.

    * Register Saved for Later unconditionally and gate it on the feature

    Once a hooked block is saved into a page or template it is baked into that
    content, so unregistering the block type when the cart_save_for_later feature
    is turned off left the editor showing an "unsupported block" notice for the
    persisted markup.

    Register the block unconditionally so persisted content stays a known block,
    and gate its behaviour on the feature instead: a block_type_metadata filter
    strips the blockHooks declaration and hides the block from the inserter when
    the feature is off, and render() returns an empty string. Auto-injection and
    rendering only happen while the feature is enabled.

    * Hide the Saved for Later editor preview when the feature is off

    The block type is registered even when cart_save_for_later is disabled, so a
    persisted block would otherwise render the sample-list preview in the editor.
    Gate the edit component on the experimentalCartSaveForLater setting: show a
    short notice instead of the mock preview while the feature is off.

    * Gate the block via block_type_metadata_settings

    Move the feature-off gating from the block_type_metadata filter to
    block_type_metadata_settings, matching the rest of the blocks codebase. It now
    edits the normalized WP_Block_Type settings (block_hooks, supports.inserter)
    rather than the raw block.json metadata, so the inserter override applies to
    the settings WordPress actually registers.

    * Read block.json with wp_json_file_decode in the test

    phpcs-changed flags file_get_contents; wp_json_file_decode reads and decodes
    the JSON file without it.

    * Update Saved for Later block: notice i18n, single instance, no default

    * Update Saved for Later tests: testdox and single-instance coverage

    * Update Saved for Later changelog entry

diff --git a/docs/block-development/reference/block-references.md b/docs/block-development/reference/block-references.md
index 7e7a4c0b1e4..724de0c989f 100644
--- a/docs/block-development/reference/block-references.md
+++ b/docs/block-development/reference/block-references.md
@@ -1613,7 +1613,7 @@ Display items the shopper has saved from their cart for later.

 - **Name:** woocommerce/saved-for-later
 - **Category:** woocommerce
-- **Supports:** align (full, wide), color (background, text), interactivity, spacing (blockGap, margin, padding), typography (fontSize, lineHeight), ~~html~~, ~~reusable~~
+- **Supports:** align (full, wide), color (background, text), interactivity, spacing (blockGap, margin, padding), typography (fontSize, lineHeight), ~~html~~, ~~multiple~~, ~~reusable~~
 - **Attributes:** columnCount

 ## Product - woocommerce/single-product
diff --git a/plugins/woocommerce/changelog/ralucaStan-saved-for-later-cart-template b/plugins/woocommerce/changelog/ralucaStan-saved-for-later-cart-template
new file mode 100644
index 00000000000..c9daf34561d
--- /dev/null
+++ b/plugins/woocommerce/changelog/ralucaStan-saved-for-later-cart-template
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Saved for Later declares its auto-injection after the Cart block via block.json blockHooks, injects wherever the Cart block is used, stays registered while the feature is off to prevent an unsupported-block notice, and allows only one instance per page
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/block.json
index 123521deb71..35a9955da87 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/block.json
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/block.json
@@ -8,10 +8,12 @@
 	"category": "woocommerce",
 	"keywords": [ "WooCommerce", "Saved for Later" ],
 	"textdomain": "woocommerce",
+	"blockHooks": {
+		"woocommerce/cart": "after"
+	},
 	"attributes": {
 		"columnCount": {
-			"type": "number",
-			"default": 5
+			"type": "number"
 		}
 	},
 	"allowedBlocks": [ "core/heading" ],
@@ -19,6 +21,7 @@
 		 "align": [ "wide", "full" ],
 		"interactivity": true,
 		"html": false,
+		"multiple": false,
 		"reusable": false,
 		"spacing": {
 			"margin": true,
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/edit.tsx
index 2e2d4ea718f..ab4e1f306d1 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/edit.tsx
@@ -7,12 +7,22 @@ import {
 	useInnerBlocksProps,
 	InspectorControls,
 } from '@wordpress/block-editor';
-import { PanelBody, RangeControl } from '@wordpress/components';
-import { Icon, trash } from '@wordpress/icons';
-import { PLACEHOLDER_IMG_SRC } from '@woocommerce/settings';
+import {
+	PanelBody,
+	RangeControl,
+	Placeholder,
+	ExternalLink,
+} from '@wordpress/components';
+import { Icon, trash, starEmpty } from '@wordpress/icons';
+import {
+	PLACEHOLDER_IMG_SRC,
+	getSettingWithCoercion,
+	getAdminLink,
+} from '@woocommerce/settings';
+import { isBoolean } from '@woocommerce/types';

 interface SavedForLaterAttributes {
-	columnCount: number;
+	columnCount?: number;
 }

 interface EditProps {
@@ -22,6 +32,9 @@ interface EditProps {

 const MIN_COLUMNS = 2;
 const MAX_COLUMNS = 6;
+// Kept in sync with the PHP-side fallback in `SavedForLater::render()` —
+// the attribute has no block.json default on purpose.
+const DEFAULT_COLUMNS = 5;

 // Lives in JS because `__()` is needed for the heading copy. `block.json`
 // strings aren't run through translation, so keeping the template here
@@ -79,7 +92,16 @@ const PREVIEW_ITEMS = [
 ];

 const Edit = ( { attributes, setAttributes }: EditProps ): JSX.Element => {
-	const { columnCount } = attributes;
+	const columnCount = attributes.columnCount ?? DEFAULT_COLUMNS;
+
+	// The block type stays registered when the `cart_save_for_later` feature is
+	// off (so content saved while it was on isn't flagged as an unsupported
+	// block). `experimentalCartSaveForLater` mirrors that feature in wcSettings.
+	const isFeatureEnabled = getSettingWithCoercion(
+		'experimentalCartSaveForLater',
+		false,
+		isBoolean
+	);

 	const blockProps = useBlockProps( {
 		className: 'wc-block-saved-for-later',
@@ -95,6 +117,38 @@ const Edit = ( { attributes, setAttributes }: EditProps ): JSX.Element => {
 		{ template: TEMPLATE }
 	);

+	// Nothing to preview when the feature is off — show a short notice instead
+	// of the sample list, so a persisted block doesn't look like a real one.
+	if ( ! isFeatureEnabled ) {
+		return (
+			<div { ...blockProps }>
+				<Placeholder
+					icon={ <Icon icon={ starEmpty } /> }
+					label={ __( 'Saved for later', 'woocommerce' ) }
+					instructions={ sprintf(
+						/* translators: %s: the feature name ("Save for Later in Cart"). */
+						__(
+							'The “%s” feature is off, so this block will not appear on your store.',
+							'woocommerce'
+						),
+						__( 'Save for Later in Cart', 'woocommerce' )
+					) }
+				>
+					<ExternalLink
+						href={ getAdminLink(
+							'admin.php?page=wc-settings&tab=advanced&section=features'
+						) }
+					>
+						{ __(
+							'Enable it in WooCommerce settings',
+							'woocommerce'
+						) }
+					</ExternalLink>
+				</Placeholder>
+			</div>
+		);
+	}
+
 	return (
 		<>
 			<InspectorControls>
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/SavedForLater.php b/plugins/woocommerce/src/Blocks/BlockTypes/SavedForLater.php
index b0d5eb8750b..b9ba3c7aa6a 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/SavedForLater.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/SavedForLater.php
@@ -6,6 +6,7 @@ namespace Automattic\WooCommerce\Blocks\BlockTypes;

 use Automattic\WooCommerce\Blocks\Utils\BlocksSharedState;
 use Automattic\WooCommerce\Internal\ShopperLists\ShopperListRenderer;
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperListsController;
 use Automattic\WooCommerce\Proxies\LegacyProxy;

 /**
@@ -19,7 +20,10 @@ use Automattic\WooCommerce\Proxies\LegacyProxy;
  * The row markup (image, name, price, remove badge, variation overlay) is
  * shared with other shopper-list blocks via `ShopperListRenderer`. This
  * class composes those fragments and adds the bits that are unique to
- * Saved for Later: auto-injection via the Block Hooks API, the
+ * Saved for Later: auto-injection after `woocommerce/cart` (declared via the
+ * `blockHooks` field in block.json so the editor treats it as a first-class
+ * hooked block; the hook, rendering, and inserter visibility are gated on the
+ * `saved-for-later` feature while the block type itself stays registered), the
  * `hasShownItems` empty-state gating, the per-row quantity span, and the
  * Move-to-cart action button.
  */
@@ -42,41 +46,58 @@ final class SavedForLater extends AbstractBlock {
 	 * Initialize this block type.
 	 */
 	protected function initialize(): void {
+		// The block type is registered unconditionally (see BlockTypesController)
+		// so content saved while the feature was on doesn't show an "unsupported
+		// block" notice once it's off. When the feature is disabled we strip the
+		// hook declaration and hide the block from the inserter, so it neither
+		// auto-injects nor can be added manually. `block_type_metadata_settings`
+		// edits the normalized `WP_Block_Type` settings and must be filtered
+		// before `parent::initialize()` registers the block.
+		add_filter( 'block_type_metadata_settings', array( $this, 'disable_when_feature_off' ), 10, 2 );
+
 		parent::initialize();

-		// We do not use `BlockHooksTrait` currently as it has issues with PHPStan.
-		add_filter( 'hooked_block_types', array( $this, 'register_hooked_block' ), 9, 4 );
+		// Seeds the default heading on the auto-injected block (only fires while
+		// the feature is on and the block is being hooked).
 		add_filter( 'hooked_block_woocommerce/saved-for-later', array( $this, 'set_hooked_block_attributes' ), 10, 4 );
 	}

 	/**
-	 * Auto-inject this block after `woocommerce/cart`, scoped to the cart page.
+	 * When the `saved-for-later` feature is disabled, drop the block's hook
+	 * declaration and hide it from the inserter. The block type itself stays
+	 * registered, so content saved while the feature was enabled keeps rendering
+	 * as a known block rather than an "unsupported block" notice.
+	 *
+	 * Runs on `block_type_metadata_settings`, so it edits the normalized settings
+	 * used to build the `WP_Block_Type` (`block_hooks`, `supports`) rather than
+	 * the raw block.json metadata.
+	 *
+	 * @internal Public only because it is a filter callback; not part of the public API.
+	 *
+	 * @since 11.2.0
 	 *
-	 * @param array                                  $hooked_block_types Block names hooked at this position.
-	 * @param string                                 $relative_position  Position of the insertion point.
-	 * @param string                                 $anchor_block_type  Anchor block name.
-	 * @param array|\WP_Post|\WP_Block_Template|null $context            Where the block is being embedded.
+	 * @param array $settings Normalized block type settings.
+	 * @param array $metadata Parsed block.json metadata (carries the block `name`).
 	 * @return array
 	 */
-	public function register_hooked_block( $hooked_block_types, $relative_position, $anchor_block_type, $context ) {
-		if ( 'after' !== $relative_position || 'woocommerce/cart' !== $anchor_block_type ) {
-			return $hooked_block_types;
+	public function disable_when_feature_off( $settings, $metadata ) {
+		if ( ! isset( $metadata['name'] ) || $this->get_full_block_name() !== $metadata['name'] || $this->is_feature_enabled() ) {
+			return $settings;
 		}

-		// `wc_get_page_id()` returns -1 when the page option isn't set.
-		$cart_page_id = (int) wc_get_page_id( 'cart' );
-		if ( $cart_page_id <= 0 || ! ( $context instanceof \WP_Post ) || (int) $context->ID !== $cart_page_id ) {
-			return $hooked_block_types;
-		}
-
-		// Don't double-inject if the block is already in the cart page
-		// content.
-		if ( has_block( $this->get_full_block_name(), $context ) ) {
-			return $hooked_block_types;
-		}
+		unset( $settings['block_hooks'] );
+		$settings['supports']             = isset( $settings['supports'] ) && is_array( $settings['supports'] ) ? $settings['supports'] : array();
+		$settings['supports']['inserter'] = false;
+		return $settings;
+	}

-		$hooked_block_types[] = $this->get_full_block_name();
-		return $hooked_block_types;
+	/**
+	 * Whether the Saved for Later feature is enabled.
+	 *
+	 * @return bool
+	 */
+	private function is_feature_enabled(): bool {
+		return wc_get_container()->get( ShopperListsController::class )->is_enabled( 'saved-for-later' );
 	}

 	/**
@@ -146,6 +167,13 @@ final class SavedForLater extends AbstractBlock {
 	 * @return string Rendered block type output.
 	 */
 	protected function render( $attributes, $content, $block ) {
+		// The block type is always registered, so a block persisted while the
+		// feature was on can still reach render() after it's turned off. Render
+		// nothing in that case.
+		if ( ! $this->is_feature_enabled() ) {
+			return '';
+		}
+
 		// Guests have no personal list — bail before enqueuing assets or seeding state.
 		if ( ! is_user_logged_in() ) {
 			return '';
diff --git a/plugins/woocommerce/src/Blocks/BlockTypesController.php b/plugins/woocommerce/src/Blocks/BlockTypesController.php
index 3171d79d05a..44cfa050716 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypesController.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypesController.php
@@ -528,9 +528,11 @@ final class BlockTypesController {
 			MiniCartContents::get_mini_cart_block_types()
 		);

-		if ( wc_get_container()->get( ShopperListsController::class )->is_enabled( 'saved-for-later' ) ) {
-			$block_types[] = 'SavedForLater';
-		}
+		// Registered unconditionally so content saved while the feature was on
+		// doesn't surface an "unsupported block" notice once it's disabled. The
+		// block gates its own auto-injection, rendering, and inserter visibility
+		// on the `saved-for-later` feature (see SavedForLater).
+		$block_types[] = 'SavedForLater';

 		if ( wc_get_container()->get( ShopperListsController::class )->is_enabled( 'wishlist' ) ) {
 			$block_types[] = 'Wishlist';
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/SavedForLaterTests.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/SavedForLaterTests.php
index 8465c94e246..0fe60f966bb 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/SavedForLaterTests.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/SavedForLaterTests.php
@@ -6,6 +6,7 @@ namespace Automattic\WooCommerce\Tests\Blocks\BlockTypes;
 use Automattic\WooCommerce\Blocks\Assets\Api;
 use Automattic\WooCommerce\Blocks\BlockTypes\SavedForLater;
 use Automattic\WooCommerce\Blocks\Package;
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
 use Automattic\WooCommerce\Proxies\LegacyProxy;
 use Automattic\WooCommerce\Tests\Blocks\Mocks\AssetDataRegistryMock;
 use ReflectionClass;
@@ -30,6 +31,20 @@ class SavedForLaterTests extends WP_UnitTestCase {
 	 */
 	private SavedForLater $sut;

+	/**
+	 * Features controller, used to toggle the `cart_save_for_later` feature.
+	 *
+	 * @var FeaturesController
+	 */
+	private FeaturesController $features_controller;
+
+	/**
+	 * The `cart_save_for_later` feature state before the test, restored on teardown.
+	 *
+	 * @var bool
+	 */
+	private bool $original_feature_enabled;
+
 	/**
 	 * Instantiate the block without invoking its constructor and inject a
 	 * registry mock so render() can call `->add()` without NPEing.
@@ -46,84 +61,137 @@ class SavedForLaterTests extends WP_UnitTestCase {
 			$this->sut,
 			new AssetDataRegistryMock( Package::container()->get( Api::class ) )
 		);
+
+		// The block gates its rendering and auto-injection on the feature, so
+		// enable it by default; the feature-off tests toggle it off explicitly.
+		$this->features_controller      = wc_get_container()->get( FeaturesController::class );
+		$this->original_feature_enabled = $this->features_controller->feature_is_enabled( 'cart_save_for_later' );
+		$this->features_controller->change_feature_enable( 'cart_save_for_later', true );
 	}

 	/**
-	 * @return array<string, array{string, string, bool, bool}>
+	 * Restore the feature flag to its pre-test state.
 	 */
-	public function provider_register_hooked_block(): array {
-		$cart_only       = '<!-- wp:woocommerce/cart /-->';
-		$cart_with_block = '<!-- wp:woocommerce/cart /--><!-- wp:woocommerce/saved-for-later /-->';
+	public function tearDown(): void {
+		$this->features_controller->change_feature_enable( 'cart_save_for_later', $this->original_feature_enabled );
+		parent::tearDown();
+	}

-		return array(
-			// label                                => array( cart_page_content, anchor, context_is_cart_page, expected_hooked ).
-			'hooked after cart on cart page'        => array( $cart_only, 'woocommerce/cart', true, true ),
-			'not hooked after non-cart anchor'      => array( $cart_only, 'core/paragraph', true, false ),
-			'not hooked when context is other page' => array( $cart_only, 'woocommerce/cart', false, false ),
-			'not hooked when already present'       => array( $cart_with_block, 'woocommerce/cart', true, false ),
-		);
+	/**
+	 * @testdox block.json declares auto-injection after the Cart block via blockHooks.
+	 *
+	 * Auto-injection after `woocommerce/cart` is declared in block.json via
+	 * `blockHooks`. Registering it on the block type (rather than through the
+	 * `hooked_block_types` filter alone) is what makes the editor treat it as a
+	 * first-class hooked block and materialize it, instead of recording it as
+	 * ignored on save. This guards that declaration.
+	 */
+	public function test_block_json_declares_block_hooks_after_cart(): void {
+		$block_json = WC_ABSPATH . 'assets/client/blocks/saved-for-later/block.json';
+		$this->assertFileExists( $block_json, 'Built saved-for-later block.json should exist.' );
+
+		$metadata = wp_json_file_decode( $block_json, array( 'associative' => true ) );
+		$this->assertIsArray( $metadata );
+		$this->assertArrayHasKey( 'blockHooks', $metadata );
+		$this->assertArrayHasKey( 'woocommerce/cart', $metadata['blockHooks'] );
+		$this->assertSame( 'after', $metadata['blockHooks']['woocommerce/cart'] );
 	}

 	/**
-	 * `register_hooked_block` only adds the block when the anchor is `woocommerce/cart`,
-	 * the context is the cart page, and the cart page doesn't already contain the block.
+	 * @testdox block.json declares supports.multiple false so only one instance can be inserted per document.
 	 *
-	 * @dataProvider provider_register_hooked_block
+	 * The block renders a shopper's single personal list, so a second instance
+	 * is never meaningful; `multiple: false` makes the editor refuse to insert
+	 * a duplicate, matching the Checkout and Mini Cart blocks.
+	 */
+	public function test_block_json_declares_single_instance_support(): void {
+		$block_json = WC_ABSPATH . 'assets/client/blocks/saved-for-later/block.json';
+		$this->assertFileExists( $block_json, 'Built saved-for-later block.json should exist.' );
+
+		$metadata = wp_json_file_decode( $block_json, array( 'associative' => true ) );
+		$this->assertIsArray( $metadata );
+		$this->assertSame( false, $metadata['supports']['multiple'] ?? null, 'supports.multiple must be false so the editor refuses a second instance.' );
+	}
+
+	/**
+	 * @testdox disable_when_feature_off strips block_hooks and hides the inserter when the feature is off.
 	 *
-	 * @param string $cart_page_content    Initial content of the cart page.
-	 * @param string $anchor               Anchor block name passed to the filter.
-	 * @param bool   $context_is_cart_page Whether the filter context is the cart page or some other page.
-	 * @param bool   $expected_hooked      Whether the block should end up in the hooked list.
+	 * The block type is registered unconditionally, but when the feature is off
+	 * `disable_when_feature_off` strips the `block_hooks` setting and hides the
+	 * block from the inserter, so it neither auto-injects nor can be added
+	 * manually while off.
 	 */
-	public function test_register_hooked_block( string $cart_page_content, string $anchor, bool $context_is_cart_page, bool $expected_hooked ): void {
-		$cart_page_id = self::factory()->post->create(
-			array(
-				'post_type'    => 'page',
-				'post_status'  => 'publish',
-				'post_content' => $cart_page_content,
-			)
+	public function test_disable_when_feature_off_strips_hooks_and_hides_inserter(): void {
+		$this->features_controller->change_feature_enable( 'cart_save_for_later', false );
+
+		$settings = array(
+			'block_hooks' => array( 'woocommerce/cart' => 'after' ),
+			'supports'    => array( 'interactivity' => true ),
 		);
-		update_option( 'woocommerce_cart_page_id', $cart_page_id );
-
-		$context_id = $context_is_cart_page
-			? $cart_page_id
-			: self::factory()->post->create(
-				array(
-					'post_type'   => 'page',
-					'post_status' => 'publish',
-				)
-			);
+		$metadata = array( 'name' => 'woocommerce/saved-for-later' );

-		$hooked = $this->sut->register_hooked_block( array(), 'after', $anchor, get_post( $context_id ) );
+		$result = $this->sut->disable_when_feature_off( $settings, $metadata );

-		if ( $expected_hooked ) {
-			$this->assertContains( 'woocommerce/saved-for-later', $hooked );
-		} else {
-			$this->assertNotContains( 'woocommerce/saved-for-later', $hooked );
-		}
+		$this->assertArrayNotHasKey( 'block_hooks', $result );
+		$this->assertFalse( $result['supports']['inserter'] );
 	}

 	/**
-	 * When the cart page option is unset, `wc_get_page_id()` returns -1 — the filter
-	 * must treat that as "no cart page" rather than letting it match a real post ID.
+	 * @testdox disable_when_feature_off leaves settings untouched when the feature is enabled.
+	 *
+	 * When the feature is enabled the settings are left untouched, so the block
+	 * keeps its `block_hooks` setting and stays inserter-visible.
 	 */
-	public function test_register_hooked_block_skips_when_cart_page_unset(): void {
-		delete_option( 'woocommerce_cart_page_id' );
+	public function test_disable_when_feature_off_keeps_settings_when_enabled(): void {
+		$this->features_controller->change_feature_enable( 'cart_save_for_later', true );

-		$context_id = self::factory()->post->create(
-			array(
-				'post_type'    => 'page',
-				'post_status'  => 'publish',
-				'post_content' => '<!-- wp:woocommerce/cart /-->',
-			)
+		$settings = array(
+			'block_hooks' => array( 'woocommerce/cart' => 'after' ),
+			'supports'    => array( 'interactivity' => true ),
 		);
+		$metadata = array( 'name' => 'woocommerce/saved-for-later' );

-		$hooked = $this->sut->register_hooked_block( array(), 'after', 'woocommerce/cart', get_post( $context_id ) );
+		$result = $this->sut->disable_when_feature_off( $settings, $metadata );

-		$this->assertNotContains( 'woocommerce/saved-for-later', $hooked );
+		$this->assertArrayHasKey( 'block_hooks', $result );
+		$this->assertSame( 'after', $result['block_hooks']['woocommerce/cart'] );
+		$this->assertArrayNotHasKey( 'inserter', $result['supports'] );
 	}

 	/**
+	 * @testdox disable_when_feature_off passes other block types through untouched.
+	 *
+	 * `disable_when_feature_off` only touches the Saved for Later block; other
+	 * blocks' settings pass through untouched even when the feature is off.
+	 */
+	public function test_disable_when_feature_off_ignores_other_blocks(): void {
+		$this->features_controller->change_feature_enable( 'cart_save_for_later', false );
+
+		$settings = array( 'block_hooks' => array( 'woocommerce/cart' => 'after' ) );
+		$metadata = array( 'name' => 'core/paragraph' );
+
+		$this->assertSame( $settings, $this->sut->disable_when_feature_off( $settings, $metadata ) );
+	}
+
+	/**
+	 * @testdox render() outputs nothing when the feature is disabled.
+	 *
+	 * A block persisted while the feature was on renders empty instead of as
+	 * content once it is turned off.
+	 */
+	public function test_render_returns_empty_when_feature_disabled(): void {
+		$this->features_controller->change_feature_enable( 'cart_save_for_later', false );
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'customer' ) ) );
+
+		$render = new ReflectionMethod( SavedForLater::class, 'render' );
+		$render->setAccessible( true );
+
+		$this->assertSame( '', (string) $render->invoke( $this->sut, array(), '', null ) );
+	}
+
+	/**
+	 * @testdox The auto-injected block is seeded with a default heading inner block.
+	 *
 	 * The auto-injected block ships with a seeded `core/heading` inner block so
 	 * fresh cart pages render the heading on the frontend out of the box. The
 	 * matching `null` push onto `innerContent` is what makes `WP_Block::render()`
@@ -162,6 +230,8 @@ class SavedForLaterTests extends WP_UnitTestCase {
 	}

 	/**
+	 * @testdox The seeded heading is appended alongside inner blocks added by extensions.
+	 *
 	 * Extensions are free to hook `hooked_block_woocommerce/saved-for-later`
 	 * to add their own inner blocks at a different priority. Our heading must
 	 * still be seeded alongside, not in place of, what they added.
@@ -201,7 +271,7 @@ class SavedForLaterTests extends WP_UnitTestCase {
 	}

 	/**
-	 * `render()` returns an empty string for logged-out shoppers.
+	 * @testdox render() returns an empty string for logged-out shoppers.
 	 */
 	public function test_render_returns_empty_for_logged_out_user(): void {
 		wp_set_current_user( 0 );
@@ -213,6 +283,8 @@ class SavedForLaterTests extends WP_UnitTestCase {
 	}

 	/**
+	 * @testdox render() seeds a hidden empty state for a shopper with no saved items.
+	 *
 	 * For a logged-in shopper whose list is empty (the new-shopper /
 	 * never-saved-an-item case), SSR must:
 	 *   - emit the empty-state `<li>` already `hidden`, so the message
@@ -276,6 +348,8 @@ class SavedForLaterTests extends WP_UnitTestCase {
 	}

 	/**
+	 * @testdox render() hides the header behind the visibility gate when the list is empty.
+	 *
 	 * The seeded heading (and any future sibling inner blocks rendered via
 	 * `$content`) must share the empty-state visibility gating: hidden on
 	 * first paint for new shoppers / empty refreshes, revealed once the
@@ -355,6 +429,8 @@ class SavedForLaterTests extends WP_UnitTestCase {
 	}

 	/**
+	 * @testdox cartPageHasSavedForLater is registered only on the cart page for logged-in shoppers.
+	 *
 	 * `cartPageHasSavedForLater` is the wcSettings flag the cart line item row reads
 	 * to decide whether to render the "Save for later" link. The block sets it
 	 * only when rendering the saved-for-later list, on the cart page, for a