Commit 1e6fc9f060b for woocommerce
commit 1e6fc9f060bd7581bbc00aff797f9ab3ccfecc60
Author: Mike Jolley <mike.jolley@me.com>
Date: Wed Oct 14 11:30:02 2026 +0100
Fix WooCommerce templates being flagged as dirty in the Site Editor (#66116)
* Fix WooCommerce templates flagged dirty in the Site Editor without edits
* Keep the local pickup title when saving the Checkout page
Saving the Checkout page copies the shipping method block's localPickupText
attribute into the pickup settings. Without the mount-time sync, the attribute
was never serialised, so any save reset a custom pickup title. Restore the sync
as a non-persistent change, and show the setting until the label is edited so
the RichText value doesn't change after mount and mark the page dirty.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Revert Shipping Method block changes to keep the pickup title intact
Saving the Checkout page copies the block's localPickupText attribute into the
pickup settings, and resets the title when the attribute is missing. Without a
persistent mount-time sync, saves that only send changed fields (Site Editor,
multi-entity save panel) reset a custom pickup title. Restore trunk's
behaviour; the page is only flagged dirty on open when its markup doesn't yet
hold the current pickup title.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
diff --git a/docs/block-development/reference/block-references.md b/docs/block-development/reference/block-references.md
index d877c5ee2ae..4e74d091ec2 100644
--- a/docs/block-development/reference/block-references.md
+++ b/docs/block-development/reference/block-references.md
@@ -1060,7 +1060,7 @@ Display a collection of products from your store.
- **Name:** woocommerce/product-collection
- **Category:** woocommerce
- **Supports:** align (full, wide), anchor, email, interactivity, ~~html~~
-- **Attributes:** __privatePreviewState, collection, convertedFromProducts, dimensions, displayLayout, forcePageReload, hideControls, query, queryContextIncludes, queryId, tagName
+- **Attributes:** collection, convertedFromProducts, dimensions, displayLayout, forcePageReload, hideControls, query, queryContextIncludes, queryId, tagName
## No results - woocommerce/product-collection-no-results
diff --git a/plugins/woocommerce/changelog/fix-48936-templates-dirty-on-open b/plugins/woocommerce/changelog/fix-48936-templates-dirty-on-open
new file mode 100644
index 00000000000..e2f64e87536
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-48936-templates-dirty-on-open
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix Cart and Checkout templates and pages showing unsaved changes in the editor when nothing was edited.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/view-switcher/utils.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/view-switcher/utils.ts
index ee2ab68c057..87d845256db 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/view-switcher/utils.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/view-switcher/utils.ts
@@ -20,8 +20,12 @@ export const selectView = (
viewName: string,
selectParent = true
) => {
- const { updateBlockAttributes, selectBlock } =
- dispatch( 'core/block-editor' );
+ const {
+ updateBlockAttributes,
+ selectBlock,
+ __unstableMarkNextChangeAsNotPersistent,
+ } = dispatch( 'core/block-editor' );
+ __unstableMarkNextChangeAsNotPersistent();
updateBlockAttributes( clientId, {
currentView: viewName,
} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/page-content-wrapper/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/page-content-wrapper/block.json
index b23829f8d5f..b6bec36a3b1 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/page-content-wrapper/block.json
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/page-content-wrapper/block.json
@@ -16,10 +16,6 @@
"default": ""
}
},
- "providesContext": {
- "postId": "postId",
- "postType": "postType"
- },
"apiVersion": 3,
"$schema": "https://schemas.wp.org/trunk/block.json"
}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/page-content-wrapper/index.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/page-content-wrapper/index.tsx
index 6cba2234f17..ee00f7d56db 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/page-content-wrapper/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/page-content-wrapper/index.tsx
@@ -7,10 +7,14 @@ import {
BlockAttributes,
} from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';
-import { InnerBlocks, useBlockProps } from '@wordpress/block-editor';
+import {
+ BlockContextProvider,
+ InnerBlocks,
+ useBlockProps,
+} from '@wordpress/block-editor';
import { page } from '@wordpress/icons';
import { CHECKOUT_PAGE_ID, CART_PAGE_ID } from '@woocommerce/block-settings';
-import { useEffect } from '@wordpress/element';
+import { useMemo } from '@wordpress/element';
/**
* Internal dependencies
@@ -18,13 +22,7 @@ import { useEffect } from '@wordpress/element';
import metadata from './block.json';
import './editor.scss';
-const Edit = ( {
- attributes,
- setAttributes,
-}: {
- attributes: BlockAttributes;
- setAttributes: ( attrs: BlockAttributes ) => void;
-} ) => {
+const Edit = ( { attributes }: { attributes: BlockAttributes } ) => {
const TEMPLATE: InnerBlockTemplate[] = [
[ 'core/post-title', { align: 'wide', level: 1 } ],
[ 'core/post-content', { align: 'wide' } ],
@@ -34,27 +32,26 @@ const Edit = ( {
className: 'wp-block-woocommerce-page-content-wrapper',
} );
- useEffect( () => {
- if ( ! attributes.postId && attributes.page ) {
- let postId = 0;
-
- if ( attributes.page === 'checkout' ) {
- postId = CHECKOUT_PAGE_ID;
- }
+ // Provide the previewed page as block context rather than writing it to
+ // attributes on mount, which flagged the template dirty on open (#48936).
+ // On the frontend the queried post supplies this context.
+ let postId = 0;
+ if ( attributes.page === 'checkout' ) {
+ postId = CHECKOUT_PAGE_ID;
+ } else if ( attributes.page === 'cart' ) {
+ postId = CART_PAGE_ID;
+ }
- if ( attributes.page === 'cart' ) {
- postId = CART_PAGE_ID;
- }
-
- if ( postId ) {
- setAttributes( { postId, postType: 'page' } );
- }
- }
- }, [ attributes, setAttributes ] );
+ const context = useMemo(
+ () => ( postId ? { postId, postType: 'page' } : {} ),
+ [ postId ]
+ );
return (
<div { ...blockProps }>
- <InnerBlocks template={ TEMPLATE } />
+ <BlockContextProvider value={ context }>
+ <InnerBlocks template={ TEMPLATE } />
+ </BlockContextProvider>
</div>
);
};
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/block.json
index 248ed5cf92b..3812a64f743 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/block.json
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/block.json
@@ -59,10 +59,6 @@
"forcePageReload": {
"type": "boolean",
"default": false
- },
- "__privatePreviewState": {
- "type": "object",
- "role": "local"
}
},
"providesContext": {
@@ -72,8 +68,7 @@
"dimensions": "dimensions",
"queryContextIncludes": "queryContextIncludes",
"collection": "collection",
- "forcePageReload": "forcePageReload",
- "__privateProductCollectionPreviewState": "__privatePreviewState"
+ "forcePageReload": "forcePageReload"
},
"usesContext": [
"templateSlug",
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/product-collection-content.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/product-collection-content.tsx
index 4311aaf913c..5f8788102b3 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/product-collection-content.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/product-collection-content.tsx
@@ -5,6 +5,7 @@ import {
useBlockProps,
useInnerBlocksProps,
store as blockEditorStore,
+ BlockContextProvider,
} from '@wordpress/block-editor';
import { useInstanceId } from '@wordpress/compose';
import { useEffect, useRef, useMemo } from '@wordpress/element';
@@ -43,14 +44,11 @@ const useQueryId = (
// @ts-ignore These selectors aren't getting their types loaded for some reason.
const { getBlockParentsByBlockName } = useSelect( blockEditorStore );
- // In order to properly support pagination this block has a queryId attribute that
- // is initialized to a unique value when the block is first added to the editor.
- // We use the `instanceId` for this purpose. It is stable across saves as long
- // as the order of instances of these blocks in the editor does not change.
- // The block will be re-indexed in that case, however, this won't cause
- // any problems since the queryid only has to be stable across client
- // renders.
- let queryId = instanceId as number;
+ // queryId must be unique per block instance (for pagination) but stable
+ // across re-mounts. Prefer the persisted value so re-mounting doesn't
+ // rewrite the attribute and flag the entity dirty on open (#48936);
+ // brand-new blocks fall back to instanceId.
+ let queryId = attributes.queryId ?? ( instanceId as number );
// We need to take special care when handling instances in a sync pattern
// to avoid an infinite loop. When two instances of a pattern are placed
@@ -82,14 +80,23 @@ const ProductCollectionContent = ( {
const isEmailEditor = useIsEmailEditor();
- useSetPreviewState( {
+ const previewState = useSetPreviewState( {
setPreviewState,
- setAttributes,
+ initialPreviewState,
location,
attributes,
isUsingReferencePreviewMode,
} );
+ // Provided to inner blocks via context instead of a persisted attribute,
+ // so deriving it never marks the entity dirty.
+ const previewStateContext = useMemo(
+ () => ( {
+ __privateProductCollectionPreviewState: previewState,
+ } ),
+ [ previewState ]
+ );
+
const blockProps = useBlockProps();
const innerBlocksProps = useInnerBlocksProps(
{},
@@ -113,11 +120,6 @@ const ProductCollectionContent = ( {
},
...( attributes as Partial< ProductCollectionAttributes > ),
queryId,
- // If initialPreviewState is provided, set it as previewState.
- ...( !! attributes.collection &&
- initialPreviewState && {
- __privatePreviewState: initialPreviewState,
- } ),
};
let style = {};
@@ -166,15 +168,13 @@ const ProductCollectionContent = ( {
return (
<div { ...blockProps }>
- { attributes.__privatePreviewState?.isPreview &&
+ { previewState?.isPreview &&
( isEmailEditor || props.isSelected ) && (
<Button
variant="primary"
size="small"
showTooltip
- label={
- attributes.__privatePreviewState?.previewMessage
- }
+ label={ previewState?.previewMessage }
className="wc-block-product-collection__preview-button"
data-testid="product-collection-preview-button"
>
@@ -185,7 +185,9 @@ const ProductCollectionContent = ( {
<InspectorControls { ...props } />
<InspectorAdvancedControls { ...props } />
<ToolbarControls { ...props } />
- <div { ...innerBlocksProps } style={ style } />
+ <BlockContextProvider value={ previewStateContext }>
+ <div { ...innerBlocksProps } style={ style } />
+ </BlockContextProvider>
</div>
);
};
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/types.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/types.ts
index fd22f318f6a..d4fdb2b4317 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/types.ts
@@ -42,8 +42,6 @@ export interface ProductCollectionAttributes {
queryContextIncludes: string[];
forcePageReload: boolean;
filterable: boolean;
- // eslint-disable-next-line @typescript-eslint/naming-convention
- __privatePreviewState?: PreviewState;
}
export enum LayoutOptions {
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/utils.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/utils.tsx
index efde7531d0c..1a440a559f7 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/utils.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/utils.tsx
@@ -3,7 +3,7 @@
*/
import { store as blockEditorStore } from '@wordpress/block-editor';
import { addFilter } from '@wordpress/hooks';
-import { select, useSelect, useDispatch } from '@wordpress/data';
+import { select, useSelect } from '@wordpress/data';
import { store as coreDataStore } from '@wordpress/core-data';
import type { BlockEditProps, Block } from '@wordpress/blocks';
import { CORE_EDITOR_STORE } from '@woocommerce/utils';
@@ -393,32 +393,23 @@ export const useProductCollectionUIState = ( {
export const useSetPreviewState = ( {
setPreviewState,
+ initialPreviewState,
location,
attributes,
- setAttributes,
isUsingReferencePreviewMode,
}: {
setPreviewState?: SetPreviewState | undefined;
+ initialPreviewState?: PreviewState | undefined;
location: WooCommerceBlockLocation;
attributes: ProductCollectionAttributes;
- setAttributes: (
- attributes: Partial< ProductCollectionAttributes >
- ) => void;
usesReference?: string[] | undefined;
isUsingReferencePreviewMode: boolean;
-} ) => {
- const { __unstableMarkNextChangeAsNotPersistent } =
- useDispatch( blockEditorStore );
-
- const setState = ( newPreviewState: PreviewState ) => {
- __unstableMarkNextChangeAsNotPersistent();
- setAttributes( {
- __privatePreviewState: {
- ...attributes.__privatePreviewState,
- ...newPreviewState,
- },
- } );
- };
+} ): PreviewState | undefined => {
+ // Preview state is editor-only UI, so keep it in local state rather than a
+ // block attribute — attribute writes mark the entity dirty (#48936).
+ const [ previewState, setState ] = useState< PreviewState | undefined >(
+ initialPreviewState
+ );
/**
* When usesReference is available on Frontend but not on Editor side,
@@ -430,20 +421,12 @@ export const useSetPreviewState = ( {
);
useEffect( () => {
if ( isUsingReferencePreviewMode ) {
- __unstableMarkNextChangeAsNotPersistent();
- setAttributes( {
- __privatePreviewState: {
- isPreview: usesReferencePreviewMessage.length > 0,
- previewMessage: usesReferencePreviewMessage,
- },
+ setState( {
+ isPreview: usesReferencePreviewMessage.length > 0,
+ previewMessage: usesReferencePreviewMessage,
} );
}
- }, [
- setAttributes,
- usesReferencePreviewMessage,
- isUsingReferencePreviewMode,
- __unstableMarkNextChangeAsNotPersistent,
- ] );
+ }, [ usesReferencePreviewMessage, isUsingReferencePreviewMode ] );
// Running setPreviewState function provided by Collection, if it exists.
useLayoutEffect( () => {
@@ -479,21 +462,24 @@ export const useSetPreviewState = ( {
? location.sourceData?.termId
: null;
useEffect( () => {
- if ( ! setPreviewState && ! isUsingReferencePreviewMode ) {
+ // Don't overwrite a collection's static `initialPreviewState` with the
+ // generic archive fallback.
+ if (
+ ! setPreviewState &&
+ ! isUsingReferencePreviewMode &&
+ ! initialPreviewState
+ ) {
const isGenericArchiveTemplate =
location.type === LocationType.Archive && termId === null;
- __unstableMarkNextChangeAsNotPersistent();
- setAttributes( {
- __privatePreviewState: {
- isPreview: isGenericArchiveTemplate
- ? !! attributes?.query?.inherit
- : false,
- previewMessage: __(
- 'Actual products will vary depending on the page being viewed.',
- 'woocommerce'
- ),
- },
+ setState( {
+ isPreview: isGenericArchiveTemplate
+ ? !! attributes?.query?.inherit
+ : false,
+ previewMessage: __(
+ 'Actual products will vary depending on the page being viewed.',
+ 'woocommerce'
+ ),
} );
}
}, [
@@ -501,11 +487,12 @@ export const useSetPreviewState = ( {
usesReferencePreviewMessage,
termId,
location.type,
- setAttributes,
setPreviewState,
+ initialPreviewState,
isUsingReferencePreviewMode,
- __unstableMarkNextChangeAsNotPersistent,
] );
+
+ return previewState;
};
export const getDefaultQueryForSettingsSection = (
currentQuery: ProductCollectionQuery
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/page-content-wrapper/page-content-wrapper.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/page-content-wrapper/page-content-wrapper.block_theme.spec.ts
index d43cd28fa48..c0b15d06662 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/page-content-wrapper/page-content-wrapper.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/page-content-wrapper/page-content-wrapper.block_theme.spec.ts
@@ -1,7 +1,12 @@
/**
* External dependencies
*/
-import { test, expect, FrontendUtils } from '@woocommerce/e2e-utils';
+import {
+ test,
+ expect,
+ FrontendUtils,
+ BLOCK_THEME_SLUG,
+} from '@woocommerce/e2e-utils';
// Instead of testing the block individually, we test the Cart and Checkout
// templates, which make use of the block.
@@ -69,5 +74,29 @@ for ( const template of templates ) {
await template.visitPage( { frontendUtils } );
await expect( page.getByText( userText ).first() ).toBeVisible();
} );
+
+ // Regression test for #48936: opening a template that uses this block
+ // must not flag it as dirty (the block previously wrote postId/postType
+ // to attributes on mount, enabling the Save button with no edits).
+ test( `opening the ${ template.title } template in the Site Editor does not enable the Save button`, async ( {
+ page,
+ admin,
+ editor,
+ } ) => {
+ await admin.visitSiteEditor( {
+ postId: `${ BLOCK_THEME_SLUG }//page-${ template.slug }`,
+ postType: 'wp_template',
+ canvas: 'edit',
+ } );
+
+ // Wait for the template content to render before asserting state.
+ await expect(
+ editor.canvas.locator( template.blockClassName )
+ ).toBeVisible();
+
+ await expect(
+ page.getByRole( 'button', { name: 'Save', exact: true } )
+ ).toBeDisabled();
+ } );
} );
}