Commit f029d85d526 for woocommerce

commit f029d85d526bec883fe0059ebd50189ba7d9d17e
Author: Oleksandr Aratovskyi <79862886+oaratovskyi@users.noreply.github.com>
Date:   Fri Aug 21 13:55:28 2026 +0300

    Fix incompatible extensions notice reappearing on gateway disable (#67108)

    * Fix incompatible extensions notice reappearing after disabling an item

    The Cart/Checkout sidebar notice persisted a dismissal against the exact
    set of incompatible slugs, so disabling an incompatible payment gateway
    or extension changed that set, invalidated the match, and re-showed a
    notice the merchant had already dismissed.

    Compare the current incompatibilities against the dismissed set as a
    subset instead of requiring exact equality, and record the union of
    acknowledged slugs on dismissal so that re-enabling a previously
    dismissed item does not resurface the notice. The notice now returns
    only when a new, never-acknowledged incompatibility appears.

    * Add changelog entry for incompatible notice dismissal fix

    * Fix storefront incompatible extensions banner reappearing on disable

    The storefront admin banner (IncompatibleExtensionsFrontendNotice)
    persisted its dismissal against the exact set of incompatible extension
    slugs, and stored it under the same localStorage key as the editor
    sidebar notice — but as a different, flat shape. Deactivating one of
    several incompatible extensions re-showed the banner, and dismissing the
    banner also overwrote the editor notice's stored dismissal, resurfacing
    that notice too.

    Give the banner its own localStorage key so the two surfaces no longer
    share storage, and match the editor notice's behaviour: compare the
    current incompatibilities against the dismissed set as a subset and
    record the union on dismissal. Its test now runs against real
    localStorage, which previously was mocked and hid the cross-surface
    collision.

    * Broaden changelog entry to cover the storefront banner fix

    * Extract the storefront banner storage key into an exported constant

    The localStorage key was hardcoded in both the component and its test, so
    a rename in one place would silently break the other (in particular the
    cross-surface regression test that asserts the editor notice's key is
    never written). Export the key from the component and import it in the
    test.

    * Carry over storefront dismissals from the pre-rename storage key

    Giving the banner its own localStorage key left every dismissal made
    since #62242 shipped in 10.7.0 behind under the shared key. Those
    merchants would see the banner one more time after upgrading — the exact
    symptom this branch exists to remove.

    Read the old key once and seed it as the hook's initial value. The hook
    only falls back to that value when the new key is genuinely absent, so
    the migration is one-shot for free. Migrating from an effect would not
    work: the hook writes the key from its own mount effect, so an "is the
    new key absent" guard never sees it absent.

    The stored value can hold both surfaces' shapes at once, since the editor
    appends its `{ [block]: slugs }` objects without discarding the bare slug
    strings the storefront wrote. Only the strings belong to the banner, so
    they are filtered out of the value rather than treated as a reason to
    skip it. Unreadable values fall back to showing the banner.

    The old key is only ever read, never written, so reverting this loses
    nothing.

    * Test the incompatibility notice hook against real stored shapes

    This key predates the storefront banner having its own, so a real site's
    value can hold shapes the hook never wrote: bare slug strings left by the
    storefront, and more than one entry for the same block. The hook already
    tolerates both, but nothing covered it, so a regression would have gone
    unnoticed.

    Also drop a dismissal that ran while the notice was hidden. With no
    notice on screen there is no control to dismiss, so the step was
    unreachable in the UI and inert for the assertion that followed.

    * Note the carried-over dismissals in the changelog entry

    The entry already covered the key split, but not that dismissals made
    before it are preserved — which is the part merchants would otherwise
    notice.

    * Warn again when a previously acknowledged incompatibility returns

    Storing the union of everything ever acknowledged made a dismissal
    permanent per slug. Disabling an incompatible gateway and re-enabling it
    left the notice hidden forever, and an incompatible gateway is not
    offered by the Cart/Checkout blocks at all, so the merchant lost the only
    warning that it had silently stopped working. Reinstalling an extension
    behaved the same way.

    Keep one invariant instead: storage holds the incompatible set the
    merchant last saw and accepted. Acknowledgements are pruned to what is
    still incompatible, so an item that leaves and comes back counts as new
    and warns, while the items that never left stay acknowledged and
    disabling one does not resurface the notice for the rest.

    Two constraints shape the pruning. It only ever removes slugs, never
    adds, so an item the merchant has not acknowledged cannot be marked as
    accepted while the notice is on screen. And it waits for the payment
    store, which reports an empty incompatible set until it initialises: a
    site with an incompatible extension plus an incompatible gateway has a
    non-zero count on first render while the gateway list is still empty, so
    pruning early would erase the acknowledgement on every editor load.

    Union-on-dismiss is now redundant, since a pruned set unioned with the
    current one is just the current one, and is dropped.

    * Tolerate malformed values in the incompatible notice storage

    Both surfaces assumed their localStorage value had the shape they wrote.
    The editor called `Object.keys( notice )` on every entry and spread
    `notice[ blockName ]`; the storefront passed its value straight to
    `isSubsetOf`. A value that is an object, a bare string, null, or an array
    holding any of those throws, and the editor notice throwing takes the
    block's inspector down with it.

    Nothing guarantees that shape. The editor key is shared with older
    versions and read by the storefront's migration, and either key is plain
    localStorage that anything on the origin can overwrite.

    Narrow both values on read and skip entries that are not the expected
    shape. An unreadable value is not a dismissal we can honour, so it falls
    back to showing the notice.

    * Extract the incompatible notice storage contract into one module

    The editor key was written as a bare literal in the hook, declared again
    as a constant on the storefront banner, and a third time in the editor's
    test, kept in step only by a comment asking the next reader to update the
    others. `isSubsetOf` was defined identically in both surfaces.

    Give both surfaces one module to import: the two keys and the subset
    check that decides whether a stored acknowledgement still covers what is
    incompatible.

    It lives next to the editor notice, which owns and writes the shared key,
    and is deliberately dependency-free. `isSubsetOf` belongs in
    `utils/array-operations`, but reaching it means importing the
    `@woocommerce/utils` barrel, and that barrel reduces a setting at import
    time, which throws when the storefront bundle pulls it in.

    * Update the changelog entry for the re-enable behaviour

    The entry promised the notices return "only when a new,
    never-acknowledged incompatibility is added", which no longer describes
    what merchants get: re-enabling a gateway or reactivating an extension
    now warns again.

    * Expose whether the site belongs to a multisite network

    Browser storage is scoped to the origin, so code that persists per-site
    state in localStorage needs to know whether other sites of a network can
    see the same value. Nothing in wcSettings said so.

    Add isMultisite to the shared core data, alongside the homeUrl the same
    callers use to identify their own site.

    * Scope the incompatible extensions dismissals to the site

    localStorage is keyed by origin, not by path, so every site of a
    subdirectory multisite reads and writes the same dismissal. Matching a
    subset made that leak worse: one site's larger acknowledgement now also
    covers a smaller set of incompatibilities on a sibling site, hiding a
    warning that Cart/Checkout may be broken there.

    Build both keys from the site's own home URL. Carry the shipped unscoped
    value over so single-site merchants keep their dismissal, but never claim
    it on a multisite, where it names no site and could belong to any of
    them. Warning an admin one more time is safer than inheriting a dismissal
    made somewhere else.

    * Stop reviving a dismissal over unreadable notice storage

    useLocalStorageState hands back its initial value in two cases it cannot
    tell apart: when the key holds nothing, and when it holds something it
    cannot parse. Both surfaces passed the carried-over dismissal as that
    initial value, so a corrupt value under this site's key resurrected a
    dismissal the merchant may have replaced long ago, and hid a warning that
    was owed. The value was then written back, erasing the evidence.

    Seed the initial value from a reader that only carries anything over when
    the key has never been written. Present but unreadable now starts empty,
    so the notice shows.

    * Note the site scoping in the changelog entry

    * Hold the notice prune back when the incompatible list is missing

    Pruning turns "this extension is no longer incompatible" into a write that
    drops the acknowledgement. Both surfaces read the incompatible extensions
    through a fallback of [], which makes a list that never arrived
    indistinguishable from one that is genuinely empty — and the setting is
    registered by the Cart and Checkout blocks, not by core data, so it can be
    absent. A woocommerce_shared_settings callback that trims the payload drops
    it, and both blocks skip registering it when get_plugins() is not loaded.
    One such page load wiped a real acknowledgement, and the next normal one
    warned again about everything the merchant had already accepted.

    Read the setting with no fallback and treat only an array as an answer, so
    "we don't know" is told apart from "none". Neither half of the incompatible
    set can be pruned alone, because a stored slug does not say which half it
    came from, so the extensions guard sits beside the payment store one.

    This also stops a non-array value from reaching .forEach, which previously
    took the storefront or the editor down.

    * Keep the site-scoped keys well formed without a home URL

    HOME_URL comes from wcSettings, which a page can load without, and it was
    interpolated into both keys unchecked. A payload missing it wrote the string
    "undefined" into the key the dismissals live under.

    Fall back to the browser's origin. That does not recover the site's identity
    — sites sharing an origin share the fallback key, exactly as they shared the
    unscoped key this replaced — but it keeps the key readable, and homeUrl is
    core data that AssetDataRegistry merges back over the shared settings filter,
    so only a payload that failed to load entirely can be missing it. Such a page
    carries no incompatible extensions list to warn about either.

    * Say when a dismissal from before scoping cannot be read

    The reader for the pre-scoping key discarded anything it could not parse, and
    anything that parsed to a shape it did not recognise, without a word. To a
    merchant that looks like the notice simply coming back after an update, with
    nothing to go on.

    Log it the way useLocalStorageState already logs its own unreadable values.
    The value is still discarded — a dismissal we cannot read is not one we can
    honour, and showing the notice stays the safe fallback.

    * Pin the storage contract the two notices share

    The notices prune their stored value on mount, which hides what these helpers
    decide: by the time a rendered notice is asserted on, the stored set and the
    incompatible set have been made equal, so a comparison demanding equality
    instead of containment passed every test. Containment is the #42469 fix, and
    it was the one thing not held down. The same went for the deliberate === null
    in the initial-value reader, whose empty-string case no test ever seeded.

    Test storage.ts directly, where nothing has run first, and add the one case
    the hook can still show it in: a wider acknowledgement while the payment
    store is loading, so the prune is held back and the comparison alone decides.

    * fix: Prune the editor notice on every shrink of the incompatible set

    The prune effect was keyed on `hasStaleAcknowledgements`, a boolean, and its
    suppression comment claimed it "goes false as soon as the pruned set is
    stored". That invariant does not hold when the incompatible set shrinks twice
    in a row: the boolean reads true on both renders, so React sees an unchanged
    dependency, never re-invokes the effect, and the second shrink is left
    unpruned.

    The editor reaches that sequence on its own. `__internalUpdateAvailablePayment
    Methods` awaits `checkPaymentMethodsCanPay` once per half, and each call
    dispatches its own set action, so the express and regular halves of the
    incompatible set land in separate renders with both initialized flags already
    true. It re-runs from `debouncedUpdatePaymentMethods` on the cart-store
    subscription, so it is not confined to first load.

    A slug left acknowledged that is no longer incompatible is silently treated as
    already seen when it comes back, which is the behaviour prune-on-load exists to
    prevent. Reproduced in the editor: a gateway that stopped being incompatible
    stayed in storage, and the notice then never warned when it returned.

    Key the effect on the set it writes instead. The pruned set is always strictly
    smaller than what is stored, so the serialisation always changes after a write
    and the effect settles.

    The storefront banner keeps the boolean: its list comes from a synchronous
    `getSetting` read that is fixed for the page lifetime, so it cannot shrink
    twice.

    Refs #42469

    * Simplify the prune effect to converge without a dependency key

    Both surfaces gated the prune write behind a dependency the effect was
    keyed on, which took a serialised set on the editor to survive
    consecutive shrinks, and a paragraph each to justify.

    Run the effect on every render instead, guarded by the null check. A
    pruned set is always strictly smaller than what is stored, so the write
    settles, and multi-render shrink sequences are handled by construction
    rather than by a carefully chosen key. The convergence suite keeps only
    the scenario the act-based suite cannot represent.

    * Trim the notice storage comments to the constraints they pin

    The comments had grown to narrate the review history: why the code is
    not written some other way, and which revision a guard answers. That
    story lives in the commits; in the file it buries the constraints.

    Keep only what the code cannot say itself: why the prune gate waits for
    both halves, why only an absent key opens the migration, and why a
    multisite claims nothing.

    * Remove storage-contract tests duplicated across the notice suites

    The storage contract — the key split, site scoping, the multisite
    refusal, migration reads, corrupt values — was proven three times: in
    the storage module's own suite and again through each surface. Three
    suites moving together on every contract change is what made the tests
    the bulk of the diff.

    The contract stays pinned in test/storage.ts alone. Each surface keeps
    what is its own: a migration happy path, the never-writes-elsewhere
    guards, its visibility and dismissal behaviour, and the corrupt-value
    case on the storefront, where seeding the legacy value straight in once
    revived an expired dismissal.

    * Update the changelog entry to lead with the merchant-visible fix

    Five sentences had accumulated, one per review round, down to which
    localStorage key each surface writes. A merchant reads the changelog for
    what changed on their store; the key split is visible in the diff.

    Two sentences remain: the dismissal behaviour on both surfaces, and the
    per-site scoping with its single-site carry-over.

    * Expose the current blog ID in the shared block settings

    Client code that keeps per-site state in the browser has no way to tell
    which site of a multisite network it is running on. `homeUrl` is the
    closest thing already in the payload, but it is not a name for a site:
    `home_url()` takes its scheme from `is_ssl()` and passes through a public
    filter, so it moves when a site switches to HTTPS and varies per request
    wherever a plugin rewrites it.

    Add `currentSiteId` from `get_current_blog_id()`, which is stable across
    all of that. It defaults to 0 rather than 1: 1 is a real blog ID, so a
    child site that never received the payload would otherwise address the
    main site's state.

    * Scope the incompatible notice storage on the blog ID

    Both notice keys were scoped on the site's home URL, which reads as a
    site identity but is not one. `home_url()` derives its scheme from
    `is_ssl()` and passes through a public filter, so the value changes on an
    HTTPS migration and differs per request under any plugin that rewrites
    it — a multilingual plugin serving a language directory, say. Each
    variant is a new key on the same origin, and because the pre-scoping key
    is only ever read, every one of them re-seeds from it. A merchant would
    be warned again for a change that has nothing to do with which site they
    are on.

    Key on the blog ID instead. It is stable across all of that, and it is
    what actually distinguishes the sites of a network sharing an origin.

    Two limits worth stating. Blog IDs are network-local, so two independent
    installs on one origin still share a key, exactly as before. And no key
    naming survives a move to another origin: that storage is unreachable,
    so those dismissals are lost whatever they are called.

    * Keep the storefront banner's storage off a shopper's page view

    The banner's admin check decided what to render, but the storage hooks
    ran above it, so every shopper's visit to Cart or Checkout read and wrote
    this site's dismissal key. `useLocalStorageState` persists its initial
    value on mount, so the first shopper to arrive left an empty array under
    a key that had never been written — and because only an absent key opens
    the migration, that closed the one-shot carry-over for good. An
    administrator arriving afterwards had lost a dismissal made before the
    keys were scoped.

    Gating the migration callback alone would not fix it; the empty write is
    what does the damage, not what the callback returns. Split the banner so
    the component holding the hooks is never mounted for a shopper.

    * Name the regression the prune convergence suite pins

    The suite said it guards against "a dependency-keyed prune effect", which
    covers two different implementations: the boolean key that was the bug,
    and the serialised key that already fixed it. A reviewer restoring the
    serialised form to test the suite found it still passed and read that as
    the suite being blind to its own regression.

    Say which one. Restoring the boolean key fails the test; the serialised
    key is an equivalent mutant that no test can separate from today's
    effect, because a prune target is always a strict subset of what is
    stored, so consecutive non-null keys can never be equal.

diff --git a/plugins/woocommerce/changelog/42469-keep-incompatible-notice-dismissed b/plugins/woocommerce/changelog/42469-keep-incompatible-notice-dismissed
new file mode 100644
index 00000000000..37f7b9b68b7
--- /dev/null
+++ b/plugins/woocommerce/changelog/42469-keep-incompatible-notice-dismissed
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep the Cart and Checkout incompatible extensions notices (editor sidebar and storefront banner) dismissed when an incompatible extension or payment gateway is disabled, and warn again only when something the merchant has not yet acknowledged becomes incompatible. Dismissals are now stored per site; existing ones carry over on single-site installs.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/incompatible-extensions-notice.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/incompatible-extensions-notice.tsx
index a95e4572b41..b7ea618ae52 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/incompatible-extensions-notice.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/incompatible-extensions-notice.tsx
@@ -2,34 +2,59 @@
  * External dependencies
  */
 import { __, sprintf } from '@wordpress/i18n';
+import { useEffect, useMemo } from '@wordpress/element';
 import { getSetting, CURRENT_USER_IS_ADMIN } from '@woocommerce/settings';
 import NoticeBanner from '@woocommerce/base-components/notice-banner';
 import { useLocalStorageState } from '@woocommerce/base-hooks';
+import {
+	getFrontendStorageKey,
+	isSubsetOf,
+	readDismissalsFromBeforeScoping,
+	readInitialDismissals,
+} from '@woocommerce/editor-components/incompatible-extension-notice/storage';

-const areArraysEqual = ( a: string[], b: string[] ): boolean => {
-	if ( a.length !== b.length ) return false;
-	const unique = new Set( [ ...a, ...b ] );
-	return unique.size === a.length;
-};
+/**
+ * The slugs the merchant acknowledged on the storefront before the keys were
+ * scoped to a site. The shared pre-scoping value usually holds both surfaces'
+ * shapes at once; only the bare strings were ever the storefront banner's, so
+ * the editor's records are filtered out rather than failing the whole value.
+ */
+const readSlugsDismissedBeforeScoping = (): string[] =>
+	readDismissalsFromBeforeScoping().filter(
+		( entry ): entry is string => typeof entry === 'string'
+	);

 interface IncompatibleExtension {
 	id: string;
 	title: string;
 }

+/**
+ * The extensions this site currently declares incompatible, and whether that
+ * list was delivered at all. `incompatibleExtensions` is registered by the
+ * Cart and Checkout blocks rather than by core data, so the payload can arrive
+ * without it — and an absent list means "we don't know", which must not be
+ * mistaken for "nothing is incompatible any more".
+ */
 const getIncompatibleExtensions = (): {
 	extensions: Record< string, string >;
 	slugs: string[];
+	isKnown: boolean;
 } => {
-	const extensions: Record< string, string > = {};
-	const data = getSetting< IncompatibleExtension[] >(
+	const data = getSetting< IncompatibleExtension[] | undefined >(
 		'incompatibleExtensions',
-		[]
+		undefined
 	);
+
+	if ( ! Array.isArray( data ) ) {
+		return { extensions: {}, slugs: [], isKnown: false };
+	}
+
+	const extensions: Record< string, string > = {};
 	data.forEach( ( ext ) => {
 		extensions[ ext.id ] = ext.title;
 	} );
-	return { extensions, slugs: Object.keys( extensions ) };
+	return { extensions, slugs: Object.keys( extensions ), isKnown: true };
 };

 interface Props {
@@ -37,27 +62,74 @@ interface Props {
 }

 /**
- * Shows a notice to admin users on the frontend when there are incompatible extensions.
+ * The banner itself, split out so the storage hooks below never mount for a
+ * shopper, who would otherwise persist an empty `[]` under this site's key and
+ * close the one-shot migration for the administrator who comes along later.
  */
-export const IncompatibleExtensionsFrontendNotice = ( {
-	block,
-}: Props ): JSX.Element | null => {
+const IncompatibleExtensionsBanner = ( { block }: Props ) => {
+	const storageKey = getFrontendStorageKey();
+
+	// Seeding the initial value migrates the pre-scoping dismissals in one shot:
+	// the hook only falls back to it when this site's key has never been
+	// written, and writes the key itself on mount. Memoised because the argument
+	// is evaluated on every render even though only the first one consumes it.
+	const initialDismissedSlugs = useMemo(
+		() =>
+			readInitialDismissals(
+				storageKey,
+				readSlugsDismissedBeforeScoping
+			),
+		[ storageKey ]
+	);
+
 	const [ dismissedSlugs, setDismissedSlugs ] = useLocalStorageState<
 		string[]
-	>( 'wc-blocks_dismissed_incompatible_extensions_notices', [] );
+	>( storageKey, initialDismissedSlugs );
+
+	// Plain localStorage that anything can overwrite, so nothing about the
+	// stored value's shape is guaranteed. Narrow it rather than let a corrupt
+	// value throw on the storefront.
+	const acknowledgedSlugs = Array.isArray( dismissedSlugs )
+		? dismissedSlugs.filter(
+				( slug ): slug is string => typeof slug === 'string'
+		  )
+		: [];

-	const { extensions, slugs } = getIncompatibleExtensions();
+	const { extensions, slugs, isKnown } = getIncompatibleExtensions();
 	const count = slugs.length;

-	const isDismissedAndUpToDate = areArraysEqual( dismissedSlugs, slugs );
+	// Stay dismissed while every currently-incompatible extension has already
+	// been acknowledged; deactivating one keeps it dismissed, while a new,
+	// never-acknowledged extension brings the notice back.
+	const isDismissedAndUpToDate = isSubsetOf( slugs, acknowledgedSlugs );

-	const shouldShow =
-		CURRENT_USER_IS_ADMIN && count > 0 && ! isDismissedAndUpToDate;
+	const shouldShow = count > 0 && ! isDismissedAndUpToDate;
+
+	// An acknowledgement only lasts while the extension stays incompatible:
+	// slugs no longer incompatible are dropped, so a reactivated extension
+	// counts as fresh and warns again, while the ones that never left stay
+	// acknowledged. It only ever removes slugs, and only when the list was
+	// actually delivered — a payload that lost the setting reports an empty
+	// list, indistinguishable from "nothing is incompatible", and pruning on
+	// that would erase a real acknowledgement.
+	const prunedAcknowledgement =
+		isKnown && ! isSubsetOf( acknowledgedSlugs, slugs )
+			? acknowledgedSlugs.filter( ( slug ) => slugs.includes( slug ) )
+			: null;
+
+	// Deliberately no dependency array: a pruned set is always strictly smaller
+	// than what is stored, so the write settles.
+	useEffect( () => {
+		if ( prunedAcknowledgement !== null ) {
+			setDismissedSlugs( prunedAcknowledgement );
+		}
+	} );

 	if ( ! shouldShow ) {
 		return null;
 	}

+	// The merchant has just seen and accepted exactly what is incompatible now.
 	const dismissNotice = () => {
 		setDismissedSlugs( slugs );
 	};
@@ -108,3 +180,17 @@ export const IncompatibleExtensionsFrontendNotice = ( {
 		</NoticeBanner>
 	);
 };
+
+/**
+ * Shows a notice to admin users on the frontend when there are incompatible extensions.
+ *
+ * Returns before the banner mounts for anyone else, so a shopper's page view
+ * never reads or writes this site's dismissal storage.
+ */
+export const IncompatibleExtensionsFrontendNotice = ( { block }: Props ) => {
+	if ( ! CURRENT_USER_IS_ADMIN ) {
+		return null;
+	}
+
+	return <IncompatibleExtensionsBanner block={ block } />;
+};
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/test/incompatible-extensions-notice.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/test/incompatible-extensions-notice.tsx
index 1dbafe10ce3..a57a3db0b70 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/test/incompatible-extensions-notice.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/test/incompatible-extensions-notice.tsx
@@ -1,23 +1,38 @@
 /**
  * External dependencies
  */
-import { render, screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
+import { render, screen, fireEvent } from '@testing-library/react';
 import { getSetting } from '@woocommerce/settings';
-import { useLocalStorageState } from '@woocommerce/base-hooks';

 /**
  * Internal dependencies
  */
+import {
+	getEditorStorageKey,
+	getFrontendStorageKey,
+	UNSCOPED_STORAGE_KEY,
+} from '@woocommerce/editor-components/incompatible-extension-notice/storage';
 import { IncompatibleExtensionsFrontendNotice } from '../incompatible-extensions-notice';

+let mockIsAdmin = true;
+
 jest.mock( '@woocommerce/settings', () => ( {
 	getSetting: jest.fn(),
-	CURRENT_USER_IS_ADMIN: true,
+	// A getter, not a value: the viewer changes between renders.
+	get CURRENT_USER_IS_ADMIN() {
+		return mockIsAdmin;
+	},
+	CURRENT_SITE_ID: 1,
+	IS_MULTISITE: false,
 } ) );

+// Use the real localStorage-backed hook (via its source module) without pulling
+// in the heavy `@woocommerce/base-hooks` barrel, so dismissal is exercised
+// against real localStorage rather than a mock.
 jest.mock( '@woocommerce/base-hooks', () => ( {
-	useLocalStorageState: jest.fn(),
+	useLocalStorageState: jest.requireActual(
+		'../../../base/hooks/use-local-storage-state'
+	).useLocalStorageState,
 } ) );

 jest.mock( '@woocommerce/base-components/notice-banner', () => ( {
@@ -41,52 +56,55 @@ jest.mock( '@woocommerce/base-components/notice-banner', () => ( {
 } ) );

 const mockGetSetting = getSetting as jest.MockedFunction< typeof getSetting >;
-const mockUseLocalStorageState = useLocalStorageState as jest.MockedFunction<
-	typeof useLocalStorageState
->;
+
+// Both come from the module, so a rename on either is caught here.
+const frontendKey = () => getFrontendStorageKey();
+const legacyKey = UNSCOPED_STORAGE_KEY;
+
+const storedSlugs = ( key = frontendKey() ) =>
+	JSON.parse( window.localStorage.getItem( key ) || '[]' );
+
+const seedFrontend = ( value: unknown ) =>
+	window.localStorage.setItem( frontendKey(), JSON.stringify( value ) );
+
+const seedLegacy = ( value: unknown ) =>
+	window.localStorage.setItem( legacyKey, JSON.stringify( value ) );
+
+const setIncompatibleExtensions = (
+	extensions: Array< { id: string; title: string } >
+) => mockGetSetting.mockImplementation( () => extensions );
+
+// The setting is registered by the Cart and Checkout blocks, not by core data,
+// so the payload can arrive without it — `getSetting` then hands back whatever
+// fallback the caller passed.
+const withoutIncompatibleExtensionsSetting = () =>
+	mockGetSetting.mockImplementation( ( _name, fallback ) => fallback );

 describe( 'IncompatibleExtensionsFrontendNotice', () => {
 	beforeEach( () => {
 		jest.clearAllMocks();
-		mockUseLocalStorageState.mockReturnValue( [ [], jest.fn() ] );
+		window.localStorage.clear();
+		setIncompatibleExtensions( [] );
+		mockIsAdmin = true;
 	} );

-	// Note: Testing CURRENT_USER_IS_ADMIN=false requires module re-mocking which
-	// conflicts with testing-library hooks. The admin check is a simple boolean
-	// guard at the top of the component, so we rely on the other tests to verify
-	// the component works correctly when the admin check passes.
-
-	describe( 'when there are no incompatible extensions', () => {
-		beforeEach( () => {
-			mockGetSetting.mockReturnValue( [] );
-		} );
-
-		it( 'should not render', () => {
+	describe( 'rendering', () => {
+		it( 'does not render when there are no incompatible extensions', () => {
 			const { container } = render(
 				<IncompatibleExtensionsFrontendNotice block="woocommerce/checkout" />
 			);
 			expect( container ).toBeEmptyDOMElement();
-			expect(
-				screen.queryByText(
-					'may not be compatible with the Checkout block'
-				)
-			).not.toBeInTheDocument();
 		} );
-	} );

-	describe( 'when there is one incompatible extension', () => {
-		beforeEach( () => {
-			mockGetSetting.mockReturnValue( [
+		it( 'renders the extension name for checkout', () => {
+			setIncompatibleExtensions( [
 				{ id: 'test-plugin', title: 'Test Plugin' },
 			] );
-		} );

-		it( 'should render notice with extension name for checkout', () => {
 			render(
 				<IncompatibleExtensionsFrontendNotice block="woocommerce/checkout" />
 			);

-			expect( screen.getByTestId( 'notice-banner' ) ).toBeInTheDocument();
 			expect( screen.getByTestId( 'notice-banner' ) ).toHaveAttribute(
 				'data-status',
 				'warning'
@@ -96,12 +114,13 @@ describe( 'IncompatibleExtensionsFrontendNotice', () => {
 					/Test Plugin may not be compatible with the Checkout block/
 				)
 			).toBeInTheDocument();
-			expect(
-				screen.getByText( /Only administrators see this notice/ )
-			).toBeInTheDocument();
 		} );

-		it( 'should render notice with extension name for cart', () => {
+		it( 'renders the extension name for cart', () => {
+			setIncompatibleExtensions( [
+				{ id: 'test-plugin', title: 'Test Plugin' },
+			] );
+
 			render(
 				<IncompatibleExtensionsFrontendNotice block="woocommerce/cart" />
 			);
@@ -113,33 +132,16 @@ describe( 'IncompatibleExtensionsFrontendNotice', () => {
 			).toBeInTheDocument();
 		} );

-		it( 'should not render a list', () => {
-			render(
-				<IncompatibleExtensionsFrontendNotice block="woocommerce/checkout" />
-			);
-
-			expect( screen.queryByRole( 'list' ) ).not.toBeInTheDocument();
-		} );
-	} );
-
-	describe( 'when there are multiple incompatible extensions', () => {
-		beforeEach( () => {
-			mockGetSetting.mockReturnValue( [
+		it( 'renders a list when there are multiple incompatible extensions', () => {
+			setIncompatibleExtensions( [
 				{ id: 'plugin-one', title: 'Plugin One' },
 				{ id: 'plugin-two', title: 'Plugin Two' },
 			] );
-		} );

-		it( 'should render notice with list of extensions', () => {
 			render(
 				<IncompatibleExtensionsFrontendNotice block="woocommerce/checkout" />
 			);

-			expect(
-				screen.getByText(
-					/Some extensions may not be compatible with the Checkout block/
-				)
-			).toBeInTheDocument();
 			expect( screen.getByRole( 'list' ) ).toBeInTheDocument();
 			expect( screen.getByText( 'Plugin One' ) ).toBeInTheDocument();
 			expect( screen.getByText( 'Plugin Two' ) ).toBeInTheDocument();
@@ -147,35 +149,61 @@ describe( 'IncompatibleExtensionsFrontendNotice', () => {
 	} );

 	describe( 'dismissal behavior', () => {
-		const mockSetDismissedNotices = jest.fn();
-
-		beforeEach( () => {
-			mockGetSetting.mockReturnValue( [
+		it( 'hides the banner and records the acknowledged extension on dismiss', () => {
+			// Seed a previously acknowledged extension that is no longer
+			// incompatible: its acknowledgement has lapsed, so the stored value
+			// ends up as exactly what the merchant just accepted.
+			seedFrontend( [ 'old-plugin' ] );
+			setIncompatibleExtensions( [
 				{ id: 'test-plugin', title: 'Test Plugin' },
 			] );
-			mockUseLocalStorageState.mockReturnValue( [
-				[],
-				mockSetDismissedNotices,
-			] );
+
+			render(
+				<IncompatibleExtensionsFrontendNotice block="woocommerce/checkout" />
+			);
+			fireEvent.click( screen.getByTestId( 'dismiss-button' ) );
+
+			expect(
+				screen.queryByTestId( 'notice-banner' )
+			).not.toBeInTheDocument();
+			expect( storedSlugs() ).toEqual( [ 'test-plugin' ] );
 		} );

-		it( 'should call setDismissedNotices when dismissed', async () => {
-			const user = userEvent.setup();
+		it( 'does not write the editor notice key (no cross-surface collision)', () => {
+			setIncompatibleExtensions( [
+				{ id: 'test-plugin', title: 'Test Plugin' },
+			] );
+
 			render(
 				<IncompatibleExtensionsFrontendNotice block="woocommerce/checkout" />
 			);
+			fireEvent.click( screen.getByTestId( 'dismiss-button' ) );

-			await user.click( screen.getByTestId( 'dismiss-button' ) );
+			expect( window.localStorage.getItem( legacyKey ) ).toBeNull();
+			expect(
+				window.localStorage.getItem( getEditorStorageKey() )
+			).toBeNull();
+		} );

-			expect( mockSetDismissedNotices ).toHaveBeenCalledWith( [
-				'test-plugin',
+		it( 'stays dismissed when an incompatible extension is deactivated', () => {
+			seedFrontend( [ 'plugin-one', 'plugin-two' ] );
+			// Only one of the two acknowledged extensions is still active.
+			setIncompatibleExtensions( [
+				{ id: 'plugin-one', title: 'Plugin One' },
 			] );
+
+			const { container } = render(
+				<IncompatibleExtensionsFrontendNotice block="woocommerce/checkout" />
+			);
+
+			expect( container ).toBeEmptyDOMElement();
 		} );

-		it( 'should not render when already dismissed with same extensions', () => {
-			mockUseLocalStorageState.mockReturnValue( [
-				[ 'test-plugin' ],
-				mockSetDismissedNotices,
+		it( 'stays dismissed while every acknowledged extension is still active', () => {
+			seedFrontend( [ 'plugin-one', 'plugin-two' ] );
+			setIncompatibleExtensions( [
+				{ id: 'plugin-one', title: 'Plugin One' },
+				{ id: 'plugin-two', title: 'Plugin Two' },
 			] );

 			const { container } = render(
@@ -185,15 +213,78 @@ describe( 'IncompatibleExtensionsFrontendNotice', () => {
 			expect( container ).toBeEmptyDOMElement();
 		} );

-		it( 'should render when dismissed but extensions changed', () => {
-			mockGetSetting.mockReturnValue( [
+		// An acknowledgement lasts only while the extension stays incompatible.
+		it( 'warns again when an acknowledged extension is deactivated and reactivated', () => {
+			seedFrontend( [ 'plugin-one', 'plugin-two' ] );
+
+			// Deactivate plugin-two. The banner stays hidden for plugin-one,
+			// and plugin-two's lapsed acknowledgement is dropped.
+			setIncompatibleExtensions( [
+				{ id: 'plugin-one', title: 'Plugin One' },
+			] );
+			const { container, unmount } = render(
+				<IncompatibleExtensionsFrontendNotice block="woocommerce/checkout" />
+			);
+			expect( container ).toBeEmptyDOMElement();
+			expect( storedSlugs() ).toEqual( [ 'plugin-one' ] );
+			unmount();
+
+			// Reactivate it: a fresh incompatibility, so the banner returns.
+			setIncompatibleExtensions( [
+				{ id: 'plugin-one', title: 'Plugin One' },
+				{ id: 'plugin-two', title: 'Plugin Two' },
+			] );
+			render(
+				<IncompatibleExtensionsFrontendNotice block="woocommerce/checkout" />
+			);
+
+			expect( screen.getByTestId( 'notice-banner' ) ).toBeInTheDocument();
+		} );
+
+		// Pruning must only ever remove slugs, never add, or it would silently
+		// accept the extension the merchant is being warned about.
+		it( 'does not acknowledge a new extension while the banner is on screen', () => {
+			seedFrontend( [ 'plugin-one', 'plugin-two' ] );
+			// plugin-two is gone and a brand-new plugin-three has arrived.
+			setIncompatibleExtensions( [
+				{ id: 'plugin-one', title: 'Plugin One' },
+				{ id: 'plugin-three', title: 'Plugin Three' },
+			] );
+
+			render(
+				<IncompatibleExtensionsFrontendNotice block="woocommerce/checkout" />
+			);
+
+			expect( screen.getByTestId( 'notice-banner' ) ).toBeInTheDocument();
+			expect( storedSlugs() ).toEqual( [ 'plugin-one' ] );
+		} );
+
+		it.each( [
+			[ 'an object', { 'plugin-one': true } ],
+			[ 'a bare string', 'plugin-one' ],
+			[ 'a number', 7 ],
+			[ 'null', null ],
+			[ 'an array of junk', [ 1, null, { a: 1 } ] ],
+		] )( 'survives a stored value that is %s', ( _label, value ) => {
+			seedFrontend( value );
+			setIncompatibleExtensions( [
+				{ id: 'plugin-one', title: 'Plugin One' },
+			] );
+
+			render(
+				<IncompatibleExtensionsFrontendNotice block="woocommerce/checkout" />
+			);
+
+			// Nothing readable was acknowledged, so the banner is still owed.
+			expect( screen.getByTestId( 'notice-banner' ) ).toBeInTheDocument();
+		} );
+
+		it( 'renders again when a new, never-acknowledged extension appears', () => {
+			seedFrontend( [ 'test-plugin' ] );
+			setIncompatibleExtensions( [
 				{ id: 'test-plugin', title: 'Test Plugin' },
 				{ id: 'new-plugin', title: 'New Plugin' },
 			] );
-			mockUseLocalStorageState.mockReturnValue( [
-				[ 'test-plugin' ],
-				mockSetDismissedNotices,
-			] );

 			render(
 				<IncompatibleExtensionsFrontendNotice block="woocommerce/checkout" />
@@ -202,10 +293,10 @@ describe( 'IncompatibleExtensionsFrontendNotice', () => {
 			expect( screen.getByTestId( 'notice-banner' ) ).toBeInTheDocument();
 		} );

-		it( 'should not render for cart when notice is dismissed (shared dismissal)', () => {
-			mockUseLocalStorageState.mockReturnValue( [
-				[ 'test-plugin' ],
-				mockSetDismissedNotices,
+		it( 'shares dismissal across cart and checkout', () => {
+			seedFrontend( [ 'test-plugin' ] );
+			setIncompatibleExtensions( [
+				{ id: 'test-plugin', title: 'Test Plugin' },
 			] );

 			const { container } = render(
@@ -215,4 +306,180 @@ describe( 'IncompatibleExtensionsFrontendNotice', () => {
 			expect( container ).toBeEmptyDOMElement();
 		} );
 	} );
+
+	// Before this split the storefront banner shared the editor's key, and
+	// neither key named a site, so a merchant's dismissal lives under the
+	// unscoped key on every site that ran 10.7.0 or later. Without a migration
+	// they would all see the banner one more time after upgrading — the exact
+	// symptom this component is meant to stop. The contract itself (site
+	// scoping, the multisite refusal, corrupt-value handling) is pinned in the
+	// storage module's own tests; these pin this surface's wiring to it.
+	describe( 'migration from the unscoped storage key', () => {
+		const renderCheckout = () =>
+			render(
+				<IncompatibleExtensionsFrontendNotice block="woocommerce/checkout" />
+			);
+
+		it( 'stays dismissed for a merchant who dismissed before the rename', () => {
+			seedLegacy( [ 'test-plugin' ] );
+			setIncompatibleExtensions( [
+				{ id: 'test-plugin', title: 'Test Plugin' },
+			] );
+
+			const { container } = renderCheckout();
+
+			expect( container ).toBeEmptyDOMElement();
+		} );
+
+		// The editor writes `{ [block]: slugs }` objects into the same key and
+		// preserves whatever the storefront left there, so a real site can hold
+		// both shapes at once.
+		it( 'migrates the storefront slugs out of a value the editor also wrote', () => {
+			seedLegacy( [
+				'test-plugin',
+				{ 'woocommerce/checkout': [ 'test-plugin', 'gateway-a' ] },
+			] );
+			setIncompatibleExtensions( [
+				{ id: 'test-plugin', title: 'Test Plugin' },
+			] );
+
+			const { container } = renderCheckout();
+
+			expect( container ).toBeEmptyDOMElement();
+			expect( storedSlugs() ).toEqual( [ 'test-plugin' ] );
+		} );
+
+		// `useLocalStorageState` hands back the initial value both when the key
+		// is missing and when it holds something it cannot parse. Only the first
+		// may reach the migration — pinned here because this surface once seeded
+		// the legacy value straight in, so a corrupt scoped value revived it.
+		it( 'does not migrate over a scoped value it cannot parse', () => {
+			window.localStorage.setItem( frontendKey(), '{not valid json' );
+			seedLegacy( [ 'test-plugin' ] );
+			setIncompatibleExtensions( [
+				{ id: 'test-plugin', title: 'Test Plugin' },
+			] );
+
+			renderCheckout();
+
+			expect( screen.getByTestId( 'notice-banner' ) ).toBeInTheDocument();
+			expect( console ).toHaveErrored();
+		} );
+
+		// Leaving the old value intact keeps a revert of this change harmless and
+		// keeps the editor notice's own dismissals working.
+		it( 'never writes to the legacy key', () => {
+			const legacy = JSON.stringify( [
+				'test-plugin',
+				{ 'woocommerce/checkout': [ 'test-plugin' ] },
+			] );
+			window.localStorage.setItem( legacyKey, legacy );
+			setIncompatibleExtensions( [
+				{ id: 'test-plugin', title: 'Test Plugin' },
+				{ id: 'new-plugin', title: 'New Plugin' },
+			] );
+
+			renderCheckout();
+			fireEvent.click( screen.getByTestId( 'dismiss-button' ) );
+
+			expect( window.localStorage.getItem( legacyKey ) ).toBe( legacy );
+		} );
+	} );
+
+	// Pruning turns "this extension is no longer incompatible" into a write that
+	// drops the acknowledgement. Everything that makes the incompatible list read
+	// as empty therefore has to be told apart from the list genuinely being
+	// empty, or a real acknowledgement is erased by a page that never had the
+	// data to judge it.
+	describe( 'when the list of incompatible extensions is not available', () => {
+		const renderCheckout = () =>
+			render(
+				<IncompatibleExtensionsFrontendNotice block="woocommerce/checkout" />
+			);
+
+		it( 'leaves the acknowledgement alone when the setting is missing', () => {
+			seedFrontend( [ 'plugin-one', 'plugin-two' ] );
+			withoutIncompatibleExtensionsSetting();
+
+			const { container } = renderCheckout();
+
+			expect( container ).toBeEmptyDOMElement();
+			expect( storedSlugs() ).toEqual( [ 'plugin-one', 'plugin-two' ] );
+		} );
+
+		// The load that lost the setting must not cost the merchant the
+		// dismissal they will need on the next, normal one.
+		it( 'still hides the banner on the next load with the setting back', () => {
+			seedFrontend( [ 'plugin-one', 'plugin-two' ] );
+			withoutIncompatibleExtensionsSetting();
+			renderCheckout();
+
+			setIncompatibleExtensions( [
+				{ id: 'plugin-one', title: 'Plugin One' },
+				{ id: 'plugin-two', title: 'Plugin Two' },
+			] );
+			const { container } = renderCheckout();
+
+			expect( container ).toBeEmptyDOMElement();
+		} );
+
+		// A shopper is never sent the list, so for them it is always missing.
+		// The admin check has to hold on its own even if it were not: a viewer
+		// who cannot see the banner must not rewrite what it is based on.
+		it( 'leaves the acknowledgement alone for a shopper', () => {
+			seedFrontend( [ 'plugin-one', 'plugin-two' ] );
+			mockIsAdmin = false;
+			// Registered and empty, so only the admin check can hold the prune.
+			setIncompatibleExtensions( [] );
+
+			const { container } = renderCheckout();
+
+			expect( container ).toBeEmptyDOMElement();
+			expect( storedSlugs() ).toEqual( [ 'plugin-one', 'plugin-two' ] );
+		} );
+
+		it( 'renders nothing for a shopper even when extensions are incompatible', () => {
+			mockIsAdmin = false;
+			setIncompatibleExtensions( [
+				{ id: 'plugin-one', title: 'Plugin One' },
+			] );
+
+			const { container } = renderCheckout();
+
+			expect( container ).toBeEmptyDOMElement();
+		} );
+
+		// The banner is split so the storage hooks never mount for a shopper.
+		// Gating only the migration callback would not do: the hook writes its
+		// initial value on mount, so a shopper would leave an empty array under
+		// this site's key and close the one-shot migration for good.
+		it( 'leaves the key absent for a shopper, so an admin can still migrate', () => {
+			seedLegacy( [ 'plugin-one' ] );
+			setIncompatibleExtensions( [
+				{ id: 'plugin-one', title: 'Plugin One' },
+			] );
+
+			mockIsAdmin = false;
+			renderCheckout().unmount();
+
+			expect( window.localStorage.getItem( frontendKey() ) ).toBeNull();
+
+			mockIsAdmin = true;
+			const { container } = renderCheckout();
+
+			expect( container ).toBeEmptyDOMElement();
+			expect( storedSlugs() ).toEqual( [ 'plugin-one' ] );
+		} );
+
+		// The control for the shopper cases above: with an admin and a list that
+		// really is empty, the lapsed acknowledgement is dropped as it should be.
+		it( 'does drop a lapsed acknowledgement for an admin', () => {
+			seedFrontend( [ 'plugin-one', 'plugin-two' ] );
+			setIncompatibleExtensions( [] );
+
+			renderCheckout();
+
+			expect( storedSlugs() ).toEqual( [] );
+		} );
+	} );
 } );
diff --git a/plugins/woocommerce/client/blocks/assets/js/editor-components/incompatible-extension-notice/storage.ts b/plugins/woocommerce/client/blocks/assets/js/editor-components/incompatible-extension-notice/storage.ts
new file mode 100644
index 00000000000..8895b3b6ce6
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/editor-components/incompatible-extension-notice/storage.ts
@@ -0,0 +1,119 @@
+/**
+ * The stored-dismissal contract shared by the editor sidebar notice and the
+ * storefront admin banner: the keys, their site scoping, and the containment
+ * check.
+ *
+ * Dependencies are kept to `@woocommerce/settings` (a webpack external both
+ * surfaces already load); barrels that run code at import time stay out,
+ * because the storefront bundle imports this module.
+ */
+
+/**
+ * External dependencies
+ */
+import { CURRENT_SITE_ID, IS_MULTISITE } from '@woocommerce/settings';
+
+/**
+ * The key both surfaces shared before either of them was scoped to a site. It
+ * is only read, never written, so a revert stays harmless and the value keeps
+ * working for a site still on the shipped version.
+ */
+export const UNSCOPED_STORAGE_KEY =
+	'wc-blocks_dismissed_incompatible_extensions_notices';
+
+/**
+ * `localStorage` is scoped to the origin, so sites of a subdirectory multisite
+ * share it; the blog ID separates them. Blog IDs are network-local, so two
+ * independent installs on one origin still share a key.
+ *
+ * Not the home URL: `home_url()` passes through a public filter, so it varies
+ * per request (a language directory, say) without the site changing.
+ *
+ * `0` is the "site unknown" fallback, and not a blog ID any site can hold.
+ */
+const scopeToSite = ( key: string ): string => `${ key }__${ CURRENT_SITE_ID }`;
+
+/**
+ * The key the editor sidebar notice reads and writes. It holds an array of
+ * `{ [blockName]: slugs }` records, one per block.
+ */
+export const getEditorStorageKey = (): string =>
+	scopeToSite( UNSCOPED_STORAGE_KEY );
+
+/**
+ * The key the storefront banner reads and writes. It holds a flat array of
+ * slugs. Kept distinct from the editor notice's key so the two surfaces don't
+ * overwrite each other's storage.
+ */
+export const getFrontendStorageKey = (): string =>
+	scopeToSite( `${ UNSCOPED_STORAGE_KEY }_frontend` );
+
+/**
+ * The dismissals stored before the keys were scoped to a site, when this site
+ * can claim them. Both surfaces shared that key, so the value can hold bare
+ * slug strings (storefront) and `{ [blockName]: slugs }` records (editor) at
+ * once; callers keep the entries their own surface wrote.
+ *
+ * Nothing is migrated on a multisite: the value names no site, so there is no
+ * telling whose dismissal it is, and warning an admin once more is safer than
+ * hiding a live warning behind another site's dismissal.
+ */
+export const readDismissalsFromBeforeScoping = (): unknown[] => {
+	if ( IS_MULTISITE ) {
+		return [];
+	}
+
+	try {
+		const stored = window.localStorage.getItem( UNSCOPED_STORAGE_KEY );
+		if ( ! stored ) {
+			return [];
+		}
+		const parsed = JSON.parse( stored );
+		if ( Array.isArray( parsed ) ) {
+			return parsed;
+		}
+	} catch {
+		// Unparseable, handled the same way as a shape we don't recognise.
+	}
+
+	// A value we can't read is not a dismissal we can honour; the log gives a
+	// merchant who reports the notice coming back something to point at.
+	// eslint-disable-next-line no-console
+	console.error(
+		`Value for key '${ UNSCOPED_STORAGE_KEY }' could not be carried over from localStorage because it can't be read as a list of dismissals.`
+	);
+	return [];
+};
+
+/**
+ * What a notice should hand `useLocalStorageState` as its initial value.
+ *
+ * That hook falls back to the initial value both when `key` holds nothing and
+ * when it holds something unparseable, and cannot tell the two apart. Only
+ * absence may open the migration: seeding a corrupt value from the pre-scoping
+ * data would revive a dismissal the merchant has since replaced and hide a
+ * warning that is currently owed.
+ */
+export const readInitialDismissals = < T >(
+	key: string,
+	migrate: () => T[]
+): T[] => {
+	try {
+		// Deliberately `=== null`: an empty string is stored data we failed to
+		// write, not an absent key, and must not open the migration path.
+		return window.localStorage.getItem( key ) === null ? migrate() : [];
+	} catch {
+		// Storage can be unavailable altogether (private browsing, blocked
+		// cookies). Nothing is stored, and nothing can be migrated into it.
+		return [];
+	}
+};
+
+/**
+ * Whether every item in `subset` is also present in `superset`.
+ *
+ * The notices stay dismissed while everything currently incompatible has
+ * already been acknowledged.
+ */
+export const isSubsetOf = ( subset: string[], superset: string[] ): boolean =>
+	subset.every( ( item ) => superset.includes( item ) );
diff --git a/plugins/woocommerce/client/blocks/assets/js/editor-components/incompatible-extension-notice/test/prune-convergence.tsx b/plugins/woocommerce/client/blocks/assets/js/editor-components/incompatible-extension-notice/test/prune-convergence.tsx
new file mode 100644
index 00000000000..583e3f6c8ec
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/editor-components/incompatible-extension-notice/test/prune-convergence.tsx
@@ -0,0 +1,204 @@
+/**
+ * The prune effect must keep converging when the incompatible set shrinks more
+ * than once while the gate stays open.
+ *
+ * `__internalUpdateAvailablePaymentMethods` awaits `checkPaymentMethodsCanPay`
+ * twice, and each call dispatches its own set action, so the express and
+ * regular halves of the incompatible set can shrink in two separate renders
+ * with both initialized flags already true.
+ *
+ * This suite deliberately runs outside React's act environment and drives a
+ * real registered store: `act()` flushes the prune's own state update between
+ * the two dispatches, which hides exactly the interleaving that once let a
+ * dependency-keyed prune effect skip the second shrink.
+ *
+ * What this pins is a **boolean** dependency, `}, [ hasStaleAcknowledgements ] )`,
+ * which reads true through both shrinks and so prunes only the first. The
+ * serialised `}, [ prunedAcknowledgementKey ] )` form was already correct, and
+ * is an equivalent mutant no test can separate from today's effect.
+ *
+ * External dependencies
+ */
+import { createElement } from '@wordpress/element';
+import { createReduxStore, register, dispatch } from '@wordpress/data';
+import { createRoot } from 'react-dom/client';
+
+const STORE = 'test/prune-convergence-payment';
+
+let mockIncompatibleExtensions: Array< { id: string; title: string } > = [];
+
+jest.mock( '@woocommerce/settings', () => ( {
+	...jest.requireActual( '@woocommerce/settings' ),
+	get CURRENT_SITE_ID() {
+		return 1;
+	},
+	get IS_MULTISITE() {
+		return false;
+	},
+	getSetting: jest.fn().mockImplementation( ( name: string, ...rest ) => {
+		if ( name === 'incompatibleExtensions' ) {
+			return mockIncompatibleExtensions;
+		}
+		return jest
+			.requireActual( '@woocommerce/settings' )
+			.getSetting( name, ...rest );
+	} ),
+} ) );
+
+// Point the hook's payment store at a real registered store so the genuine
+// `useSelect` subscription drives re-renders.
+jest.mock( '@woocommerce/block-data', () => ( {
+	__esModule: true,
+	paymentStore: 'test/prune-convergence-payment',
+} ) );
+
+/**
+ * Internal dependencies
+ */
+import { useCombinedIncompatibilityNotice } from '../use-combined-incompatibility-notice';
+import { getEditorStorageKey } from '../storage';
+
+type State = {
+	express: Record< string, string >;
+	regular: Record< string, string >;
+	initialized: boolean;
+};
+
+const store = createReduxStore( STORE, {
+	reducer: (
+		state: State = { express: {}, regular: {}, initialized: false },
+		action: { type: string; value?: Record< string, string > }
+	): State => {
+		switch ( action.type ) {
+			case 'SET_EXPRESS':
+				return { ...state, express: action.value ?? {} };
+			case 'SET_REGULAR':
+				return { ...state, regular: action.value ?? {} };
+			case 'INITIALIZE':
+				return { ...state, initialized: true };
+			case 'DEINITIALIZE':
+				return { ...state, initialized: false };
+			default:
+				return state;
+		}
+	},
+	actions: {
+		setExpress: ( value: Record< string, string > ) => ( {
+			type: 'SET_EXPRESS',
+			value,
+		} ),
+		setRegular: ( value: Record< string, string > ) => ( {
+			type: 'SET_REGULAR',
+			value,
+		} ),
+		initialize: () => ( { type: 'INITIALIZE' } ),
+		deinitialize: () => ( { type: 'DEINITIALIZE' } ),
+	},
+	selectors: {
+		// Memoised on the same state slices production's `createSelector` uses,
+		// so `useSelect` sees a stable reference for unchanged state exactly as
+		// it does against the real payment store.
+		getIncompatiblePaymentMethods: ( () => {
+			let lastExpress: unknown;
+			let lastRegular: unknown;
+			let lastResult: Record< string, string > = {};
+			return ( state: State ) => {
+				if (
+					state.express !== lastExpress ||
+					state.regular !== lastRegular
+				) {
+					lastExpress = state.express;
+					lastRegular = state.regular;
+					lastResult = { ...state.express, ...state.regular };
+				}
+				return lastResult;
+			};
+		} )(),
+		paymentMethodsInitialized: ( state: State ) => state.initialized,
+		expressPaymentMethodsInitialized: ( state: State ) => state.initialized,
+	},
+} );
+register( store );
+
+const CHECKOUT = 'woocommerce/checkout';
+
+const Harness = () => {
+	useCombinedIncompatibilityNotice( CHECKOUT );
+	return null;
+};
+
+const acknowledgedSlugs = () =>
+	JSON.parse(
+		window.localStorage.getItem( getEditorStorageKey() ) || 'null'
+	)?.[ 0 ]?.[ CHECKOUT ];
+
+// Lets React's scheduler run its post-commit work, the way a real frame does.
+// Several turns, not one: passive effects flush on a scheduler task, and the
+// state update a prune makes needs a further render to reach `localStorage`.
+const settle = async () => {
+	for ( let turn = 0; turn < 5; turn++ ) {
+		await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
+	}
+};
+
+describe( 'prune convergence across consecutive shrinks', () => {
+	let container: HTMLDivElement;
+	let root: ReturnType< typeof createRoot >;
+	let wasActEnvironment: unknown;
+
+	beforeEach( () => {
+		wasActEnvironment = ( global as Record< string, unknown > )
+			.IS_REACT_ACT_ENVIRONMENT;
+		( global as Record< string, unknown > ).IS_REACT_ACT_ENVIRONMENT =
+			false;
+
+		window.localStorage.clear();
+		mockIncompatibleExtensions = [ { id: 'ext_x', title: 'Ext X' } ];
+		dispatch( STORE ).setExpress( { gw_express: 'Express Gateway' } );
+		dispatch( STORE ).setRegular( { gw_regular: 'Regular Gateway' } );
+		dispatch( STORE ).initialize();
+
+		container = document.createElement( 'div' );
+		document.body.appendChild( container );
+		root = createRoot( container );
+	} );
+
+	afterEach( () => {
+		root.unmount();
+		container.remove();
+		( global as Record< string, unknown > ).IS_REACT_ACT_ENVIRONMENT =
+			wasActEnvironment;
+	} );
+
+	// The one prune scenario the act-based suite cannot represent; everything
+	// else about pruning lives in test/use-combined-incompatibility-notice.ts.
+	it( 'drops both halves when the express and regular sets shrink in separate renders', async () => {
+		// The merchant acknowledged everything that was incompatible.
+		window.localStorage.setItem(
+			getEditorStorageKey(),
+			JSON.stringify( [
+				{ [ CHECKOUT ]: [ 'ext_x', 'gw_express', 'gw_regular' ] },
+			] )
+		);
+
+		root.render( createElement( Harness ) );
+		await settle();
+		expect( acknowledgedSlugs() ).toEqual( [
+			'ext_x',
+			'gw_express',
+			'gw_regular',
+		] );
+
+		// Mirrors __internalUpdateAvailablePaymentMethods: two awaited calls,
+		// each dispatching its own set action, with the gate already open.
+		await Promise.resolve();
+		dispatch( STORE ).setExpress( {} );
+		await Promise.resolve();
+		dispatch( STORE ).setRegular( {} );
+		await settle();
+
+		// Both gateways stopped being incompatible, so neither acknowledgement
+		// may survive: if either comes back it has to warn again.
+		expect( acknowledgedSlugs() ).toEqual( [ 'ext_x' ] );
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/editor-components/incompatible-extension-notice/test/storage.ts b/plugins/woocommerce/client/blocks/assets/js/editor-components/incompatible-extension-notice/test/storage.ts
new file mode 100644
index 00000000000..acefbe4e7b6
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/editor-components/incompatible-extension-notice/test/storage.ts
@@ -0,0 +1,258 @@
+/**
+ * The storage contract both incompatibility notices share.
+ *
+ * Tested directly rather than only through the two notices, because the notices
+ * prune their stored value on mount and that prune hides what these helpers
+ * decide: by the time a rendered notice is asserted on, the stored set and the
+ * incompatible set have been made equal, so a comparison that demanded equality
+ * instead of containment would look correct there. Containment is the #42469
+ * fix, so it is pinned here where nothing has run before it.
+ */
+
+// Two sites of one subdirectory multisite. Same origin, so they share the
+// browser's localStorage; different blog IDs, so they must not share a key.
+const SITE_A = 1;
+const SITE_B = 2;
+
+let mockSiteId = SITE_A;
+let mockIsMultisite = false;
+// Exposed only so the tests below can prove the keys ignore it.
+let mockHomeUrl = 'http://example.com/';
+
+jest.mock( '@woocommerce/settings', () => ( {
+	// Getters, not values: the site under test changes between calls.
+	get CURRENT_SITE_ID() {
+		return mockSiteId;
+	},
+	get IS_MULTISITE() {
+		return mockIsMultisite;
+	},
+	get HOME_URL() {
+		return mockHomeUrl;
+	},
+} ) );
+
+/**
+ * Internal dependencies
+ */
+import {
+	getEditorStorageKey,
+	getFrontendStorageKey,
+	isSubsetOf,
+	readDismissalsFromBeforeScoping,
+	readInitialDismissals,
+	UNSCOPED_STORAGE_KEY,
+} from '../storage';
+
+describe( 'incompatible extension notice storage', () => {
+	beforeEach( () => {
+		window.localStorage.clear();
+		mockSiteId = SITE_A;
+		mockIsMultisite = false;
+		mockHomeUrl = 'http://example.com/';
+	} );
+
+	describe( 'storage keys', () => {
+		it( 'gives the two surfaces different keys', () => {
+			expect( getEditorStorageKey() ).not.toBe( getFrontendStorageKey() );
+		} );
+
+		it( 'keeps both keys off the value earlier versions wrote', () => {
+			expect( getEditorStorageKey() ).not.toBe( UNSCOPED_STORAGE_KEY );
+			expect( getFrontendStorageKey() ).not.toBe( UNSCOPED_STORAGE_KEY );
+		} );
+
+		it( 'scopes both keys to the site', () => {
+			const editorOnA = getEditorStorageKey();
+			const frontendOnA = getFrontendStorageKey();
+
+			mockSiteId = SITE_B;
+
+			expect( getEditorStorageKey() ).not.toBe( editorOnA );
+			expect( getFrontendStorageKey() ).not.toBe( frontendOnA );
+		} );
+
+		// The home URL is not an input, so the per-request variation it carries
+		// (here a language directory) cannot orphan a dismissal. Only
+		// same-origin variation matters: an actual move to another origin gets
+		// its own localStorage whatever the key is called.
+		it( 'keeps the same keys when the home URL varies on one origin', () => {
+			const editorBefore = getEditorStorageKey();
+			const frontendBefore = getFrontendStorageKey();
+
+			mockHomeUrl = 'http://example.com/fr/';
+
+			expect( getEditorStorageKey() ).toBe( editorBefore );
+			expect( getFrontendStorageKey() ).toBe( frontendBefore );
+		} );
+
+		it( 'builds both keys from the blog ID', () => {
+			expect( getEditorStorageKey() ).toBe(
+				`${ UNSCOPED_STORAGE_KEY }__${ SITE_A }`
+			);
+			expect( getFrontendStorageKey() ).toBe(
+				`${ UNSCOPED_STORAGE_KEY }_frontend__${ SITE_A }`
+			);
+		} );
+	} );
+
+	describe( 'readDismissalsFromBeforeScoping', () => {
+		it( 'returns the stored list', () => {
+			window.localStorage.setItem(
+				UNSCOPED_STORAGE_KEY,
+				JSON.stringify( [
+					'ext-one',
+					{ 'woocommerce/cart': [ 'gw' ] },
+				] )
+			);
+
+			expect( readDismissalsFromBeforeScoping() ).toEqual( [
+				'ext-one',
+				{ 'woocommerce/cart': [ 'gw' ] },
+			] );
+		} );
+
+		it( 'returns nothing, and stays quiet, when the key is absent', () => {
+			expect( readDismissalsFromBeforeScoping() ).toEqual( [] );
+		} );
+
+		it.each( [
+			[ 'unparseable', 'not json at all' ],
+			[ 'an object', JSON.stringify( { a: 1 } ) ],
+			[ 'a bare string', JSON.stringify( 'ext-one' ) ],
+			[ 'null', JSON.stringify( null ) ],
+		] )( 'discards a value that is %s, and says so', ( _label, stored ) => {
+			window.localStorage.setItem( UNSCOPED_STORAGE_KEY, stored );
+
+			expect( readDismissalsFromBeforeScoping() ).toEqual( [] );
+			expect( console ).toHaveErrored();
+		} );
+
+		// The value names no site and every site on the origin sees it, so on a
+		// multisite there is no telling whose dismissal it is.
+		it( 'claims nothing on a multisite', () => {
+			mockIsMultisite = true;
+			window.localStorage.setItem(
+				UNSCOPED_STORAGE_KEY,
+				JSON.stringify( [ 'ext-one' ] )
+			);
+
+			expect( readDismissalsFromBeforeScoping() ).toEqual( [] );
+		} );
+
+		it( 'does not even read the key on a multisite', () => {
+			mockIsMultisite = true;
+			const getItem = jest.spyOn( Storage.prototype, 'getItem' );
+
+			readDismissalsFromBeforeScoping();
+
+			expect( getItem ).not.toHaveBeenCalled();
+			getItem.mockRestore();
+		} );
+	} );
+
+	describe( 'readInitialDismissals', () => {
+		const KEY = 'some-key';
+		const migrate = () => [ 'migrated' ];
+
+		it( 'migrates when the key has never been written', () => {
+			expect( readInitialDismissals( KEY, migrate ) ).toEqual( [
+				'migrated',
+			] );
+		} );
+
+		// `useLocalStorageState` falls back to the initial value both when the
+		// key is missing and when it holds something it cannot parse, and cannot
+		// tell the two apart. Only absence may open the migration: seeding a
+		// corrupt value from the pre-scoping key would revive a dismissal the
+		// merchant has since replaced and hide a warning that is owed now.
+		it.each( [
+			[ 'a readable value', JSON.stringify( [ 'stored' ] ) ],
+			[ 'an empty array', JSON.stringify( [] ) ],
+			[ 'something unparseable', '{not valid json' ],
+			// The case the `=== null` check exists for: an empty string is a
+			// write that failed, which is stored data, not an absent key.
+			[ 'an empty string', '' ],
+		] )( 'starts empty when the key already holds %s', ( _l, stored ) => {
+			window.localStorage.setItem( KEY, stored );
+			const migrateSpy = jest.fn( migrate );
+
+			expect( readInitialDismissals( KEY, migrateSpy ) ).toEqual( [] );
+			expect( migrateSpy ).not.toHaveBeenCalled();
+		} );
+
+		// Private browsing and blocked cookies can make storage throw outright.
+		it( 'starts empty when storage cannot be read at all', () => {
+			const getItem = jest
+				.spyOn( Storage.prototype, 'getItem' )
+				.mockImplementation( () => {
+					throw new Error( 'denied' );
+				} );
+			const migrateSpy = jest.fn( migrate );
+
+			expect( readInitialDismissals( KEY, migrateSpy ) ).toEqual( [] );
+			expect( migrateSpy ).not.toHaveBeenCalled();
+			getItem.mockRestore();
+		} );
+	} );
+
+	// Containment, not equality. The notices stay dismissed while everything
+	// currently incompatible has been acknowledged, and an acknowledgement that
+	// covers more than that — because an extension was deactivated since — still
+	// counts. Demanding equality is exactly the #42469 bug.
+	describe( 'isSubsetOf', () => {
+		it.each( [
+			{
+				when: 'nothing is incompatible',
+				sub: [],
+				sup: [ 'a', 'b' ],
+				is: true,
+			},
+			{
+				when: 'both sides match',
+				sub: [ 'a', 'b' ],
+				sup: [ 'a', 'b' ],
+				is: true,
+			},
+			{
+				when: 'the order differs',
+				sub: [ 'a', 'b' ],
+				sup: [ 'b', 'a' ],
+				is: true,
+			},
+			// One of two acknowledged extensions was deactivated: #42469.
+			{
+				when: 'more was acknowledged than is incompatible',
+				sub: [ 'a' ],
+				sup: [ 'a', 'b' ],
+				is: true,
+			},
+			{
+				when: 'nothing was acknowledged',
+				sub: [ 'a' ],
+				sup: [],
+				is: false,
+			},
+			{
+				when: 'one incompatible item is new',
+				sub: [ 'a', 'b' ],
+				sup: [ 'a' ],
+				is: false,
+			},
+			{
+				when: 'the two sets are disjoint',
+				sub: [ 'a' ],
+				sup: [ 'b' ],
+				is: false,
+			},
+			{ when: 'neither side has anything', sub: [], sup: [], is: true },
+		] )( 'is $is when $when', ( { sub, sup, is } ) => {
+			expect( isSubsetOf( sub, sup ) ).toBe( is );
+		} );
+
+		it( 'does not consider the two sides interchangeable', () => {
+			expect( isSubsetOf( [ 'a' ], [ 'a', 'b' ] ) ).toBe( true );
+			expect( isSubsetOf( [ 'a', 'b' ], [ 'a' ] ) ).toBe( false );
+		} );
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/editor-components/incompatible-extension-notice/test/use-combined-incompatibility-notice.ts b/plugins/woocommerce/client/blocks/assets/js/editor-components/incompatible-extension-notice/test/use-combined-incompatibility-notice.ts
new file mode 100644
index 00000000000..311c601d584
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/editor-components/incompatible-extension-notice/test/use-combined-incompatibility-notice.ts
@@ -0,0 +1,433 @@
+/**
+ * External dependencies
+ */
+import * as wpData from '@wordpress/data';
+import { renderHook, act } from '@testing-library/react';
+
+// Mock @wordpress/data before importing the hook under test.
+jest.mock( '@wordpress/data', () => ( {
+	__esModule: true,
+	...jest.requireActual( '@wordpress/data' ),
+	useSelect: jest.fn(),
+} ) );
+
+// Incompatible payment gateways returned by the payment store, controllable per render.
+let mockIncompatiblePaymentMethods: Record< string, string > = {};
+
+// Whether the payment store has finished loading. Until it has, it reports an
+// empty incompatible set, which the hook must not read as "nothing is
+// incompatible any more".
+let mockPaymentMethodsLoaded = true;
+
+// Incompatible extensions returned from settings, controllable per render.
+// `undefined` stands for the setting never reaching the payload, which is not
+// the same thing as an empty list: the Cart and Checkout blocks register it
+// themselves, so a `woocommerce_shared_settings` callback that trims the
+// settings drops it and `getSetting` falls back to what the caller passed.
+let mockIncompatibleExtensions:
+	| Array< { id: string; title: string } >
+	| undefined = [];
+
+jest.mock( '@woocommerce/settings', () => ( {
+	...jest.requireActual( '@woocommerce/settings' ),
+	CURRENT_SITE_ID: 1,
+	IS_MULTISITE: false,
+	getSetting: jest.fn().mockImplementation( ( name: string, ...rest ) => {
+		if ( name === 'incompatibleExtensions' ) {
+			return mockIncompatibleExtensions;
+		}
+		return jest
+			.requireActual( '@woocommerce/settings' )
+			.getSetting( name, ...rest );
+	} ),
+} ) );
+
+/**
+ * Internal dependencies
+ */
+import { useCombinedIncompatibilityNotice } from '../use-combined-incompatibility-notice';
+import { getEditorStorageKey, UNSCOPED_STORAGE_KEY } from '../storage';
+
+( wpData.useSelect as jest.Mock ).mockImplementation( ( mapSelect ) =>
+	mapSelect( () => ( {
+		getIncompatiblePaymentMethods: () =>
+			mockPaymentMethodsLoaded ? mockIncompatiblePaymentMethods : {},
+		paymentMethodsInitialized: () => mockPaymentMethodsLoaded,
+		expressPaymentMethodsInitialized: () => mockPaymentMethodsLoaded,
+	} ) )
+);
+
+const CHECKOUT = 'woocommerce/checkout';
+const CART = 'woocommerce/cart';
+
+// Returns whether the notice is visible for the given block on a fresh mount
+// (fresh mount == a page reload, since state is seeded from localStorage).
+const mountVisibility = ( block: string ) => {
+	const { result, unmount } = renderHook( () =>
+		useCombinedIncompatibilityNotice( block )
+	);
+	const isVisible = result.current[ 0 ];
+	unmount();
+	return isVisible;
+};
+
+// Dismisses the notice for the given block on a fresh mount, then unmounts.
+const mountAndDismiss = ( block: string ) => {
+	const { result, unmount } = renderHook( () =>
+		useCombinedIncompatibilityNotice( block )
+	);
+	act( () => {
+		result.current[ 1 ]();
+	} );
+	unmount();
+};
+
+const storageKey = () => getEditorStorageKey();
+
+const storedNotices = ( key = storageKey() ) =>
+	JSON.parse( window.localStorage.getItem( key ) || '[]' );
+
+describe( 'useCombinedIncompatibilityNotice', () => {
+	beforeEach( () => {
+		window.localStorage.clear();
+		mockIncompatiblePaymentMethods = {};
+		mockIncompatibleExtensions = [];
+		mockPaymentMethodsLoaded = true;
+	} );
+
+	it( 'shows the notice when there is an incompatible gateway', () => {
+		mockIncompatiblePaymentMethods = {
+			gw_a: 'Gateway A',
+			gw_b: 'Gateway B',
+		};
+
+		expect( mountVisibility( CHECKOUT ) ).toBe( true );
+	} );
+
+	it( 'hides the notice when there are no incompatibilities', () => {
+		expect( mountVisibility( CHECKOUT ) ).toBe( false );
+	} );
+
+	it( 'keeps the notice hidden after the merchant dismisses it', () => {
+		mockIncompatiblePaymentMethods = {
+			gw_a: 'Gateway A',
+			gw_b: 'Gateway B',
+		};
+
+		const { result, rerender } = renderHook( () =>
+			useCombinedIncompatibilityNotice( CHECKOUT )
+		);
+		expect( result.current[ 0 ] ).toBe( true );
+
+		act( () => {
+			result.current[ 1 ]();
+		} );
+		rerender();
+
+		expect( result.current[ 0 ] ).toBe( false );
+	} );
+
+	// Core of issue #42469.
+	it( 'stays dismissed when an incompatible gateway is disabled', () => {
+		mockIncompatiblePaymentMethods = {
+			gw_a: 'Gateway A',
+			gw_b: 'Gateway B',
+		};
+		mountAndDismiss( CHECKOUT );
+
+		// Reload after disabling gateway B — only A remains incompatible.
+		mockIncompatiblePaymentMethods = { gw_a: 'Gateway A' };
+
+		expect( mountVisibility( CHECKOUT ) ).toBe( false );
+	} );
+
+	it( 'shows the notice again when a new incompatible gateway appears', () => {
+		mockIncompatiblePaymentMethods = {
+			gw_a: 'Gateway A',
+			gw_b: 'Gateway B',
+		};
+		mountAndDismiss( CHECKOUT );
+
+		// A brand-new, never-acknowledged incompatible gateway appears.
+		mockIncompatiblePaymentMethods = {
+			gw_a: 'Gateway A',
+			gw_b: 'Gateway B',
+			gw_c: 'Gateway C',
+		};
+
+		expect( mountVisibility( CHECKOUT ) ).toBe( true );
+	} );
+
+	// An acknowledgement lasts only while the gateway stays incompatible: the
+	// merchant accepted it being unusable at checkout *then*, which says nothing
+	// about turning it back on later.
+	it( 'warns again when an acknowledged gateway is disabled and re-enabled', () => {
+		mockIncompatiblePaymentMethods = {
+			gw_a: 'Gateway A',
+			gw_b: 'Gateway B',
+		};
+		mountAndDismiss( CHECKOUT );
+
+		// Disable B. The notice stays hidden for the still-incompatible A,
+		// which is the #42469 fix.
+		mockIncompatiblePaymentMethods = { gw_a: 'Gateway A' };
+		expect( mountVisibility( CHECKOUT ) ).toBe( false );
+
+		// Re-enable B. It stopped being incompatible in between, so it counts
+		// as a fresh incompatibility and warns.
+		mockIncompatiblePaymentMethods = {
+			gw_a: 'Gateway A',
+			gw_b: 'Gateway B',
+		};
+
+		expect( mountVisibility( CHECKOUT ) ).toBe( true );
+	} );
+
+	it( 'drops the acknowledgement of a disabled gateway from storage', () => {
+		mockIncompatiblePaymentMethods = {
+			gw_a: 'Gateway A',
+			gw_b: 'Gateway B',
+		};
+		mountAndDismiss( CHECKOUT );
+
+		mockIncompatiblePaymentMethods = { gw_a: 'Gateway A' };
+		mountVisibility( CHECKOUT );
+
+		expect( storedNotices() ).toEqual( [ { [ CHECKOUT ]: [ 'gw_a' ] } ] );
+	} );
+
+	// Pruning must only ever remove slugs. If it wrote the current set wholesale
+	// it would silently acknowledge the gateway the merchant is being warned about.
+	it( 'does not acknowledge a new gateway while the notice is on screen', () => {
+		mockIncompatiblePaymentMethods = {
+			gw_a: 'Gateway A',
+			gw_b: 'Gateway B',
+		};
+		mountAndDismiss( CHECKOUT );
+
+		// B goes away and a brand-new C arrives, so the notice is showing for C.
+		mockIncompatiblePaymentMethods = {
+			gw_a: 'Gateway A',
+			gw_c: 'Gateway C',
+		};
+		expect( mountVisibility( CHECKOUT ) ).toBe( true );
+
+		// B was pruned, but C was not silently accepted, so it still warns.
+		expect( storedNotices() ).toEqual( [ { [ CHECKOUT ]: [ 'gw_a' ] } ] );
+		expect( mountVisibility( CHECKOUT ) ).toBe( true );
+	} );
+
+	// The payment store reports an empty set until it has loaded. Pruning on
+	// that would wipe the acknowledgement on every single editor load.
+	it( 'keeps acknowledgements while the payment store is still loading', () => {
+		mockIncompatiblePaymentMethods = {
+			gw_a: 'Gateway A',
+			gw_b: 'Gateway B',
+		};
+		mountAndDismiss( CHECKOUT );
+
+		mockPaymentMethodsLoaded = false;
+		expect( mountVisibility( CHECKOUT ) ).toBe( false );
+		expect( storedNotices() ).toEqual( [
+			{ [ CHECKOUT ]: [ 'gw_a', 'gw_b' ] },
+		] );
+
+		// Once it loads, both are still incompatible and still acknowledged.
+		mockPaymentMethodsLoaded = true;
+		expect( mountVisibility( CHECKOUT ) ).toBe( false );
+	} );
+
+	it( 'tracks dismissal independently per block', () => {
+		mockIncompatiblePaymentMethods = { gw_a: 'Gateway A' };
+		mountAndDismiss( CHECKOUT );
+
+		// The cart block has its own, still-undismissed notice.
+		expect( mountVisibility( CART ) ).toBe( true );
+	} );
+
+	// The notice stays dismissed while everything currently incompatible has been
+	// acknowledged, even when the acknowledgement covers more than that. Asserted
+	// with the payment store still loading, because once it has loaded the prune
+	// makes the two sets equal and a comparison demanding equality would pass
+	// here too — which is the #42469 bug.
+	it( 'stays dismissed off a wider acknowledgement while the store loads', () => {
+		mockIncompatibleExtensions = [ { id: 'ext_x', title: 'Ext X' } ];
+		mockIncompatiblePaymentMethods = { gw_a: 'Gateway A' };
+		mountAndDismiss( CHECKOUT );
+		expect( storedNotices() ).toEqual( [
+			{ [ CHECKOUT ]: [ 'ext_x', 'gw_a' ] },
+		] );
+
+		// Reload with the store still loading: it reports no gateways, so only
+		// the extension is incompatible, and the acknowledgement covers it and
+		// one thing more.
+		mockPaymentMethodsLoaded = false;
+
+		expect( mountVisibility( CHECKOUT ) ).toBe( false );
+		// Held back, so nothing was pruned on the strength of an empty set.
+		expect( storedNotices() ).toEqual( [
+			{ [ CHECKOUT ]: [ 'ext_x', 'gw_a' ] },
+		] );
+	} );
+
+	// Pruning turns "this is no longer incompatible" into a write that drops the
+	// acknowledgement, so every reason the incompatible set can read as empty has
+	// to be told apart from it genuinely being empty. The payment store has its
+	// own guard; the extensions half needs the same one.
+	describe( 'when the list of incompatible extensions is not available', () => {
+		it( 'leaves the acknowledgement alone when the setting is missing', () => {
+			mockIncompatibleExtensions = [ { id: 'ext_x', title: 'Ext X' } ];
+			mockIncompatiblePaymentMethods = { gw_a: 'Gateway A' };
+			mountAndDismiss( CHECKOUT );
+
+			mockIncompatibleExtensions = undefined;
+			mountVisibility( CHECKOUT );
+
+			expect( storedNotices() ).toEqual( [
+				{ [ CHECKOUT ]: [ 'ext_x', 'gw_a' ] },
+			] );
+		} );
+
+		it( 'still hides the notice on the next load with the setting back', () => {
+			mockIncompatibleExtensions = [ { id: 'ext_x', title: 'Ext X' } ];
+			mockIncompatiblePaymentMethods = { gw_a: 'Gateway A' };
+			mountAndDismiss( CHECKOUT );
+
+			mockIncompatibleExtensions = undefined;
+			mountVisibility( CHECKOUT );
+
+			mockIncompatibleExtensions = [ { id: 'ext_x', title: 'Ext X' } ];
+			expect( mountVisibility( CHECKOUT ) ).toBe( false );
+		} );
+
+		// The control: with the list delivered and the extension really gone,
+		// the lapsed acknowledgement is dropped as it should be.
+		it( 'does drop a lapsed acknowledgement once the list arrives', () => {
+			mockIncompatibleExtensions = [ { id: 'ext_x', title: 'Ext X' } ];
+			mockIncompatiblePaymentMethods = { gw_a: 'Gateway A' };
+			mountAndDismiss( CHECKOUT );
+
+			mockIncompatibleExtensions = [];
+			mountVisibility( CHECKOUT );
+
+			expect( storedNotices() ).toEqual( [
+				{ [ CHECKOUT ]: [ 'gw_a' ] },
+			] );
+		} );
+	} );
+
+	// Before the storefront banner had its own key, and before either key was
+	// scoped to a site, both surfaces wrote into one. A migrated value can
+	// therefore still hold shapes this hook never wrote: bare slug strings left
+	// by the storefront, and more than one entry for the same block.
+	describe( 'values left by earlier versions', () => {
+		const seed = ( value: unknown ) =>
+			window.localStorage.setItem(
+				storageKey(),
+				JSON.stringify( value )
+			);
+
+		it( 'reads acknowledgements split across several entries for one block', () => {
+			seed( [
+				{ [ CHECKOUT ]: [ 'gw_a' ] },
+				{ [ CHECKOUT ]: [ 'gw_b' ] },
+			] );
+			mockIncompatiblePaymentMethods = {
+				gw_a: 'Gateway A',
+				gw_b: 'Gateway B',
+			};
+
+			expect( mountVisibility( CHECKOUT ) ).toBe( false );
+		} );
+
+		it( 'consolidates those entries into one on the next dismissal', () => {
+			seed( [
+				{ [ CHECKOUT ]: [ 'gw_a' ] },
+				{ [ CHECKOUT ]: [ 'gw_b' ] },
+			] );
+			mockIncompatiblePaymentMethods = {
+				gw_a: 'Gateway A',
+				gw_b: 'Gateway B',
+				gw_c: 'Gateway C',
+			};
+
+			mountAndDismiss( CHECKOUT );
+
+			expect( storedNotices() ).toEqual( [
+				{ [ CHECKOUT ]: [ 'gw_a', 'gw_b', 'gw_c' ] },
+			] );
+		} );
+
+		it( 'ignores bare slug strings the storefront banner left behind', () => {
+			seed( [ 'gw_a' ] );
+			mockIncompatiblePaymentMethods = { gw_a: 'Gateway A' };
+
+			// The string is the storefront's acknowledgement, not this block's,
+			// so the editor notice is still owed to the merchant.
+			expect( mountVisibility( CHECKOUT ) ).toBe( true );
+		} );
+
+		it.each( [
+			[ 'an object', { [ CHECKOUT ]: [ 'gw_a' ] } ],
+			[ 'a bare string', 'gw_a' ],
+			[ 'a number', 7 ],
+			[ 'null', null ],
+			[ 'an array holding junk', [ null, 7, { [ CHECKOUT ]: 'gw_a' } ] ],
+		] )( 'survives a stored value that is %s', ( _label, value ) => {
+			seed( value );
+			mockIncompatiblePaymentMethods = { gw_a: 'Gateway A' };
+
+			// Nothing readable was acknowledged, so the notice is still owed,
+			// and reading it must not throw.
+			expect( mountVisibility( CHECKOUT ) ).toBe( true );
+		} );
+
+		it( 'preserves those strings when writing its own dismissal', () => {
+			seed( [ 'gw_a' ] );
+			mockIncompatiblePaymentMethods = { gw_a: 'Gateway A' };
+
+			mountAndDismiss( CHECKOUT );
+
+			expect( storedNotices() ).toEqual( [
+				'gw_a',
+				{ [ CHECKOUT ]: [ 'gw_a' ] },
+			] );
+		} );
+	} );
+
+	// The dismissals merchants already made live under the unscoped key, so
+	// without a migration every one of them would see the notice one more time.
+	// Site scoping, the multisite refusal, and the corrupt-value rules are the
+	// storage contract's and are pinned in test/storage.ts; these two pin this
+	// surface's wiring to it.
+	describe( 'migration from before site scoping', () => {
+		const seedUnscoped = ( value: unknown ) =>
+			window.localStorage.setItem(
+				UNSCOPED_STORAGE_KEY,
+				JSON.stringify( value )
+			);
+
+		it( 'stays dismissed for a merchant who dismissed before scoping', () => {
+			seedUnscoped( [ { [ CHECKOUT ]: [ 'gw_a' ] } ] );
+			mockIncompatiblePaymentMethods = { gw_a: 'Gateway A' };
+
+			expect( mountVisibility( CHECKOUT ) ).toBe( false );
+		} );
+
+		// Leaving the old value intact keeps a revert of this change harmless.
+		it( 'never writes to the unscoped key', () => {
+			const before = JSON.stringify( [ { [ CHECKOUT ]: [ 'gw_a' ] } ] );
+			window.localStorage.setItem( UNSCOPED_STORAGE_KEY, before );
+			mockIncompatiblePaymentMethods = {
+				gw_a: 'Gateway A',
+				gw_b: 'Gateway B',
+			};
+
+			mountAndDismiss( CHECKOUT );
+
+			expect( window.localStorage.getItem( UNSCOPED_STORAGE_KEY ) ).toBe(
+				before
+			);
+		} );
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/editor-components/incompatible-extension-notice/use-combined-incompatibility-notice.ts b/plugins/woocommerce/client/blocks/assets/js/editor-components/incompatible-extension-notice/use-combined-incompatibility-notice.ts
index 418838c5ab7..b2faf4492b8 100644
--- a/plugins/woocommerce/client/blocks/assets/js/editor-components/incompatible-extension-notice/use-combined-incompatibility-notice.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/editor-components/incompatible-extension-notice/use-combined-incompatibility-notice.ts
@@ -1,7 +1,9 @@
 /**
  * External dependencies
  */
-import { useState, useEffect } from '@wordpress/element';
+import { useState, useEffect, useMemo } from '@wordpress/element';
+import { useSelect } from '@wordpress/data';
+import { paymentStore } from '@woocommerce/block-data';
 import { useLocalStorageState } from '@woocommerce/base-hooks';

 /**
@@ -9,22 +11,44 @@ import { useLocalStorageState } from '@woocommerce/base-hooks';
  */
 import { useIncompatiblePaymentGatewaysNotice } from './use-incompatible-payment-gateways-notice';
 import { useIncompatibleExtensionNotice } from './use-incompatible-extensions-notice';
+import {
+	getEditorStorageKey,
+	isSubsetOf,
+	readDismissalsFromBeforeScoping,
+	readInitialDismissals,
+} from './storage';

 type StoredIncompatibleExtension = { [ k: string ]: string[] };
-const initialDismissedNotices: React.SetStateAction<
-	StoredIncompatibleExtension[]
-> = [];

-const areEqual = ( array1: string[], array2: string[] ) => {
-	if ( array1.length !== array2.length ) {
-		return false;
+// The editor's key is its own, but its contents are not guaranteed: anything
+// can overwrite localStorage, and the first value can come from the pre-scoping
+// key both surfaces shared. Reads below tolerate any JSON value.
+const isPlainObject = ( value: unknown ): value is Record< string, unknown > =>
+	typeof value === 'object' && value !== null && ! Array.isArray( value );
+
+// The pre-scoping key was shared with the storefront banner, which wrote bare
+// slug strings into the same array; only the records were ever the editor's.
+// Their contents stay untrusted, and `readSlugsFor` re-checks every slug.
+const isStoredNotice = (
+	value: unknown
+): value is StoredIncompatibleExtension => isPlainObject( value );
+
+const readNoticesDismissedBeforeScoping = (): StoredIncompatibleExtension[] =>
+	readDismissalsFromBeforeScoping().filter( isStoredNotice );
+
+const readSlugsFor = ( notice: unknown, blockName: string ): string[] => {
+	if ( ! isPlainObject( notice ) ) {
+		return [];
 	}
-
-	const uniqueCollectionValues = new Set( [ ...array1, ...array2 ] );
-
-	return uniqueCollectionValues.size === array1.length;
+	const slugs = notice[ blockName ];
+	return Array.isArray( slugs )
+		? slugs.filter( ( slug ): slug is string => typeof slug === 'string' )
+		: [];
 };

+const holdsEntryFor = ( notice: unknown, blockName: string ): boolean =>
+	isPlainObject( notice ) && blockName in notice;
+
 const sortAlphabetically = ( obj: {
 	[ key: string ]: string;
 } ): { [ key: string ]: string } =>
@@ -41,6 +65,7 @@ export const useCombinedIncompatibilityNotice = (
 		incompatibleExtensions,
 		incompatibleExtensionSlugs,
 		incompatibleExtensionCount,
+		areIncompatibleExtensionsKnown,
 	] = useIncompatibleExtensionNotice();

 	const [
@@ -49,6 +74,16 @@ export const useCombinedIncompatibilityNotice = (
 		incompatiblePaymentMethodCount,
 	] = useIncompatiblePaymentGatewaysNotice();

+	// Until both are true the payment store reports an empty incompatible set,
+	// which is indistinguishable from "nothing is incompatible any more".
+	const arePaymentMethodsLoaded = useSelect( ( select ) => {
+		const { paymentMethodsInitialized, expressPaymentMethodsInitialized } =
+			select( paymentStore );
+		return (
+			paymentMethodsInitialized() && expressPaymentMethodsInitialized()
+		);
+	}, [] );
+
 	const allIncompatibleItems = {
 		...incompatibleExtensions,
 		...incompatiblePaymentMethods,
@@ -62,61 +97,99 @@ export const useCombinedIncompatibilityNotice = (
 	const allIncompatibleItemCount =
 		incompatibleExtensionCount + incompatiblePaymentMethodCount;

+	const storageKey = getEditorStorageKey();
+
+	// Seeding the initial value migrates the pre-scoping dismissals in one shot:
+	// the hook only falls back to it when this site's key has never been
+	// written, and writes the key itself on mount. Memoised because the argument
+	// is evaluated on every render even though only the first one consumes it.
+	const initialDismissedNotices = useMemo(
+		() =>
+			readInitialDismissals(
+				storageKey,
+				readNoticesDismissedBeforeScoping
+			),
+		[ storageKey ]
+	);
+
 	const [ dismissedNotices, setDismissedNotices ] = useLocalStorageState<
 		StoredIncompatibleExtension[]
-	>(
-		`wc-blocks_dismissed_incompatible_extensions_notices`,
-		initialDismissedNotices
-	);
+	>( storageKey, initialDismissedNotices );

 	const [ isVisible, setIsVisible ] = useState( false );

-	const isDismissedNoticeUpToDate = dismissedNotices.some(
-		( notice ) =>
-			Object.keys( notice ).includes( blockName ) &&
-			areEqual(
-				notice[ blockName as keyof object ],
-				allIncompatibleItemSlugs
-			)
+	const storedNotices: unknown[] = Array.isArray( dismissedNotices )
+		? dismissedNotices
+		: [];
+
+	// Every incompatible item the merchant has already dismissed for this block.
+	// Reduce (not find) so we tolerate the legacy shape where a single block
+	// could have accumulated multiple stored entries.
+	const dismissedItemSlugs = storedNotices.reduce< string[] >(
+		( acc, notice ) => {
+			acc.push( ...readSlugsFor( notice, blockName ) );
+			return acc;
+		},
+		[]
+	);
+
+	// The notice stays dismissed as long as every currently-incompatible item
+	// has already been acknowledged. Removing an item (e.g. disabling a gateway)
+	// keeps it dismissed; a brand-new, never-acknowledged item brings it back.
+	const isDismissedNoticeUpToDate = isSubsetOf(
+		allIncompatibleItemSlugs,
+		dismissedItemSlugs
 	);

 	const shouldBeDismissed =
 		allIncompatibleItemCount === 0 || isDismissedNoticeUpToDate;

+	// Replaces every entry this block may have accumulated with a single record
+	// of `slugs`, leaving other blocks' entries untouched.
+	const storeAcknowledgedSlugs = ( slugs: string[] ) =>
+		setDismissedNotices( ( notices ) => [
+			...( Array.isArray( notices ) ? notices : [] ).filter(
+				( notice ) => ! holdsEntryFor( notice, blockName )
+			),
+			{ [ blockName ]: slugs },
+		] );
+
+	// The merchant has just seen and accepted exactly what is incompatible now.
 	const dismissNotice = () => {
-		const dismissedNoticesSet = new Set( dismissedNotices );
-		dismissedNoticesSet.add( {
-			[ blockName ]: allIncompatibleItemSlugs,
-		} );
-		setDismissedNotices( [ ...dismissedNoticesSet ] );
+		storeAcknowledgedSlugs( allIncompatibleItemSlugs );
 	};

-	// This ensures the modal is not loaded on first render. This is required so
+	// An acknowledgement only lasts while the item stays incompatible: slugs no
+	// longer incompatible are dropped, so an item that goes away and comes back
+	// counts as fresh and warns again, while the items that never left stay
+	// acknowledged (the #42469 fix). It only ever removes slugs, and is held
+	// back until both halves of the incompatible set are known — the payment
+	// store before it loads and a missing `incompatibleExtensions` setting both
+	// report an empty set, indistinguishable from "nothing is incompatible".
+	const prunedAcknowledgement =
+		arePaymentMethodsLoaded &&
+		areIncompatibleExtensionsKnown &&
+		! isSubsetOf( dismissedItemSlugs, allIncompatibleItemSlugs )
+			? dismissedItemSlugs.filter( ( slug ) =>
+					allIncompatibleItemSlugs.includes( slug )
+			  )
+			: null;
+
+	// Deliberately no dependency array: a pruned set is always strictly smaller
+	// than what is stored, so the write settles, and re-checking on every
+	// render is what keeps consecutive shrinks of the incompatible set from
+	// slipping past a memoised gate.
+	useEffect( () => {
+		if ( prunedAcknowledgement !== null ) {
+			storeAcknowledgedSlugs( prunedAcknowledgement );
+		}
+	} );
+
+	// This ensures the notice is not shown on first render. This is required so
 	// Gutenberg doesn't steal the focus from the Guide and focuses the block.
 	useEffect( () => {
 		setIsVisible( ! shouldBeDismissed );
-
-		if ( ! shouldBeDismissed && ! isDismissedNoticeUpToDate ) {
-			setDismissedNotices( ( previousDismissedNotices ) =>
-				previousDismissedNotices.reduce(
-					( acc: StoredIncompatibleExtension[], curr ) => {
-						if ( Object.keys( curr ).includes( blockName ) ) {
-							return acc;
-						}
-						acc.push( curr );
-
-						return acc;
-					},
-					[]
-				)
-			);
-		}
-	}, [
-		shouldBeDismissed,
-		isDismissedNoticeUpToDate,
-		setDismissedNotices,
-		blockName,
-	] );
+	}, [ shouldBeDismissed ] );

 	return [
 		isVisible,
diff --git a/plugins/woocommerce/client/blocks/assets/js/editor-components/incompatible-extension-notice/use-incompatible-extensions-notice.ts b/plugins/woocommerce/client/blocks/assets/js/editor-components/incompatible-extension-notice/use-incompatible-extensions-notice.ts
index 116023e33e0..fdc15545071 100644
--- a/plugins/woocommerce/client/blocks/assets/js/editor-components/incompatible-extension-notice/use-incompatible-extensions-notice.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/editor-components/incompatible-extension-notice/use-incompatible-extensions-notice.ts
@@ -3,22 +3,37 @@
  */
 import { getSetting } from '@woocommerce/settings';

+/**
+ * The extensions this site currently declares incompatible.
+ *
+ * The fourth element says whether the list was delivered at all, which an empty
+ * list on its own cannot. `incompatibleExtensions` is registered by the Cart and
+ * Checkout blocks rather than by core data, so a `woocommerce_shared_settings`
+ * callback that trims the settings drops it, and both blocks skip registering it
+ * when `get_plugins()` is not loaded. Callers that act on an extension having
+ * *stopped* being incompatible need to tell "we don't know" apart from "none".
+ */
 export const useIncompatibleExtensionNotice = (): [
-	{ [ k: string ]: string } | null,
+	{ [ k: string ]: string },
 	string[],
 	number,
+	boolean,
 ] => {
 	interface GlobalIncompatibleExtensions {
 		id: string;
 		title: string;
 	}

+	const declared = getSetting< GlobalIncompatibleExtensions[] | undefined >(
+		'incompatibleExtensions',
+		undefined
+	);
+	const areIncompatibleExtensionsKnown = Array.isArray( declared );
+
 	const incompatibleExtensions: Record< string, string > = {};

-	if ( getSetting( 'incompatibleExtensions' ) ) {
-		getSetting< GlobalIncompatibleExtensions[] >(
-			'incompatibleExtensions'
-		).forEach( ( extension ) => {
+	if ( areIncompatibleExtensionsKnown ) {
+		declared.forEach( ( extension ) => {
 			incompatibleExtensions[ extension.id ] = extension.title;
 		} );
 	}
@@ -30,5 +45,6 @@ export const useIncompatibleExtensionNotice = (): [
 		incompatibleExtensions,
 		incompatibleExtensionSlugs,
 		incompatibleExtensionCount,
+		areIncompatibleExtensionsKnown,
 	];
 };
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/settings/default-constants.ts b/plugins/woocommerce/client/blocks/packages/public-api/settings/default-constants.ts
index b8908283b6b..a70b4ded36b 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/settings/default-constants.ts
+++ b/plugins/woocommerce/client/blocks/packages/public-api/settings/default-constants.ts
@@ -14,8 +14,10 @@ import { getCurrencyPrefix, getCurrencySuffix } from './utils';
  */
 export const ADMIN_URL = allSettings.adminUrl;
 export const COUNTRIES = allSettings.countries;
+export const CURRENT_SITE_ID = allSettings.currentSiteId as number;
 export const CURRENT_USER_IS_ADMIN = allSettings.currentUserIsAdmin as boolean;
 export const HOME_URL = allSettings.homeUrl as string | undefined;
+export const IS_MULTISITE = allSettings.isMultisite as boolean;
 export const LOCALE = allSettings.locale;
 export const ORDER_STATUSES = allSettings.orderStatuses;
 export const PLACEHOLDER_IMG_SRC = allSettings.placeholderImgSrc as string;
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/settings/settings-init.ts b/plugins/woocommerce/client/blocks/packages/public-api/settings/settings-init.ts
index 27b386f5bfb..a094464dd24 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/settings/settings-init.ts
+++ b/plugins/woocommerce/client/blocks/packages/public-api/settings/settings-init.ts
@@ -57,9 +57,11 @@ export interface WooCommerceSharedSettings {
 		}
 	>;
 	currency: WooCommerceSiteCurrency;
+	currentSiteId: number;
 	currentUserId: number;
 	currentUserIsAdmin: boolean;
 	homeUrl: string;
+	isMultisite: boolean;
 	locale: WooCommerceSiteLocale;
 	orderStatuses: Record< string, string > | never[];
 	placeholderImgSrc: string;
@@ -93,9 +95,13 @@ const defaults: WooCommerceSharedSettings = {
 		priceFormat: '%1$s%2$s',
 		thousandSeparator: ',',
 	},
+	// Not 1: that is a real blog ID, and a multisite child that never received
+	// the payload would impersonate the main site and write into its storage.
+	currentSiteId: 0,
 	currentUserId: 0,
 	currentUserIsAdmin: false,
 	homeUrl: '',
+	isMultisite: false,
 	locale: {
 		siteLocale: 'en_US',
 		userLocale: 'en_US',
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/settings/test/default-constants.ts b/plugins/woocommerce/client/blocks/packages/public-api/settings/test/default-constants.ts
new file mode 100644
index 00000000000..a7cb1638582
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/packages/public-api/settings/test/default-constants.ts
@@ -0,0 +1,13 @@
+/**
+ * Internal dependencies
+ */
+import { CURRENT_SITE_ID } from '../default-constants';
+
+describe( 'default constants', () => {
+	// No `window.wcSettings` under test, so this is what a consumer reads when
+	// the payload never arrived. Not 1: that is a real blog ID, and a multisite
+	// child would address the main site's per-site state with it.
+	it( 'CURRENT_SITE_ID falls back to 0, which is not a usable blog ID', () => {
+		expect( CURRENT_SITE_ID ).toBe( 0 );
+	} );
+} );
diff --git a/plugins/woocommerce/src/Blocks/Assets/AssetDataRegistry.php b/plugins/woocommerce/src/Blocks/Assets/AssetDataRegistry.php
index 15a8e832319..3c8725743ac 100644
--- a/plugins/woocommerce/src/Blocks/Assets/AssetDataRegistry.php
+++ b/plugins/woocommerce/src/Blocks/Assets/AssetDataRegistry.php
@@ -87,11 +87,13 @@ class AssetDataRegistry {
 			'adminUrl'               => admin_url(),
 			'countries'              => WC()->countries->get_countries(),
 			'currency'               => $this->get_currency_data(),
+			'currentSiteId'          => get_current_blog_id(),
 			'currentUserId'          => get_current_user_id(),
 			'currentUserIsAdmin'     => current_user_can( 'manage_woocommerce' ),
 			'currentThemeIsFSETheme' => wp_is_block_theme(),
 			'dateFormat'             => wc_date_format(),
 			'homeUrl'                => esc_url( home_url( '/' ) ),
+			'isMultisite'            => is_multisite(),
 			'locale'                 => $this->get_locale_data(),
 			'isRemoteLoggingEnabled' => wc_get_container()->get( RemoteLogger::class )->is_remote_logging_allowed(),
 			'dashboardUrl'           => wc_get_account_endpoint_url( 'dashboard' ),