Commit d746bf7c886 for woocommerce
commit d746bf7c8866cbb804c7da1498365f4844b4bbe1
Author: Rostislav Wolný <1082140+costasovo@users.noreply.github.com>
Date: Wed Jul 29 10:23:39 2026 +0200
[Email Editor] Reduce typing latency in the editor canvas (#67031)
* Memoize getPersonalizationTagsList and its entity query
A block edit filter calls this selector once per block on every store
change - roughly 90 times per keystroke in a 14-block email - and each
call built a fresh query object and re-filtered the whole tag list.
The fresh query object is the expensive half. getEntityRecords forwards
it to core-data's getQueriedItems, which rememo memoizes by argument
reference, so a new literal misses on every call and prepends a node to
a list that is only discarded when the entity state changes; later calls
then walk all of it. Reusing one query object per post id and caching
the filtered result removes that work while leaving the filtering
semantics unchanged.
Editing a template with an unresolved template record previously threw a
TypeError on null; it now filters out tags that declare post types.
Refs WOOPLUG-7223
* Memoize the user theme returned by useUserTheme
The hook returned a fresh object literal on every render, so the
mergedConfig memo in useEmailCss could never hit. That re-ran deepmerge
and generateGlobalStyles on every render of InnerEditor and produced a
new styles array, which replaced the whole core/block-editor settings
object once per keystroke and invalidated memoization throughout the
block editor even though the generated CSS was byte-identical.
The memo is keyed on the global styles post's settings and styles rather
than on the post itself. Editing styles goes through editEntityRecord,
which replaces both values, so the stylesheet still regenerates while
typing in the canvas no longer touches it.
Refs WOOPLUG-7223
* Add changelog entries for the email editor canvas fixes
Both the JS package and the plugin ship the affected code, so each needs
its own entry. The two preceding commits are a single user-visible
change - faster typing in the editor canvas - so they share one entry
per package rather than one per fix.
Refs WOOPLUG-7223
* Share one query builder between the tags selector and invalidation
getPersonalizationTagsList and invalidatePersonalizationTagsCache each
built their own { context, per_page, post_id } literal. invalidateResolution
matches resolver arguments structurally, so had the two shapes ever
drifted the invalidation would have stopped matching silently rather than
failing loudly - and once the selector's copy moved into a closure, nobody
editing actions.ts would have seen it to keep them in sync.
Both now call getPersonalizationTagsQuery, which also provides the
referential stability the selector needs. It is keyed per post id rather
than holding a single slot, so alternating between posts cannot evict the
entry and send either caller back to a fresh object on every call.
Refs WOOPLUG-7223
* Cover the settings dependency in the useUserTheme test
The three existing tests all passed with `globalStylePost?.settings`
removed from the memo dependencies, so nothing pinned it. That gap is
worse than a stale value: `useGlobalStylesOutputWithConfig` bails out to
an empty stylesheet unless it has both styles and settings, so a settings
change that failed to invalidate the memo would drop the generated CSS
entirely rather than leave it out of date.
Extends the existing styles-change scenario with a settings-only edit
rather than adding a second fixture, so no new mocking is introduced.
Refs WOOPLUG-7223
diff --git a/packages/js/email-editor/changelog/performance-editor-canvas-typing-latency b/packages/js/email-editor/changelog/performance-editor-canvas-typing-latency
new file mode 100644
index 00000000000..58d10e09d77
--- /dev/null
+++ b/packages/js/email-editor/changelog/performance-editor-canvas-typing-latency
@@ -0,0 +1,4 @@
+Significance: patch
+Type: performance
+
+Stop regenerating the global styles stylesheet and re-filtering personalization tags on every render, which made typing in the editor canvas sluggish.
diff --git a/packages/js/email-editor/src/hooks/test/use-user-theme.spec.ts b/packages/js/email-editor/src/hooks/test/use-user-theme.spec.ts
new file mode 100644
index 00000000000..8083d8ecc02
--- /dev/null
+++ b/packages/js/email-editor/src/hooks/test/use-user-theme.spec.ts
@@ -0,0 +1,128 @@
+/**
+ * External dependencies
+ */
+import { renderHook } from '@testing-library/react';
+import { useSelect } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import { useUserTheme } from '../use-user-theme';
+import { storeName } from '../../store/constants';
+
+jest.mock( '@wordpress/data', () => {
+ const actual =
+ jest.requireActual< typeof import('@wordpress/data') >(
+ '@wordpress/data'
+ );
+
+ return Object.create( actual, {
+ useSelect: { value: jest.fn() },
+ dispatch: {
+ value: jest.fn( () => ( { editEntityRecord: jest.fn() } ) ),
+ },
+ } );
+} );
+
+jest.mock( '@wordpress/core-data', () => ( { store: { name: 'core' } } ) );
+
+// The store barrel pulls in `@wordpress/components` through the events module,
+// which Jest cannot transform. Re-export the real constant so the store name
+// stays linked to `src/store/constants.ts`.
+jest.mock( '../../store', () => ( {
+ storeName: jest.requireActual< typeof import('../../store/constants') >(
+ '../../store/constants'
+ ).storeName,
+} ) );
+
+const mockedUseSelect = useSelect as unknown as jest.Mock;
+
+/**
+ * Runs the hook's own `mapSelect` against a stub store, so the store it selects
+ * and its `|| null` normalisation are exercised rather than stubbed over.
+ *
+ * @param post Global styles post the stubbed selector should return.
+ */
+function mockGlobalStylePost( post: unknown ) {
+ mockedUseSelect.mockImplementation(
+ ( mapSelect: ( s: unknown ) => unknown ) =>
+ mapSelect( ( store: unknown ) => {
+ if ( store !== storeName ) {
+ throw new Error( `Unexpected store: ${ String( store ) }` );
+ }
+ return { getGlobalEmailStylesPost: () => post };
+ } )
+ );
+}
+
+describe( 'useUserTheme', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ // Consumers use `userTheme` as a memo dependency for regenerating the global
+ // stylesheet, so a fresh object per render makes those memos always miss.
+ it( 'keeps the same userTheme across re-renders when nothing changed', () => {
+ mockGlobalStylePost( {
+ id: 1,
+ styles: { color: { background: '#fff' } },
+ settings: { color: { palette: [] } },
+ } );
+
+ const { result, rerender } = renderHook( () => useUserTheme() );
+ const first = result.current.userTheme;
+
+ rerender();
+
+ expect( result.current.userTheme ).toBe( first );
+ } );
+
+ // The counterpart: an edit must still produce a new object, or the canvas
+ // would never restyle. Both values are checked because
+ // `useGlobalStylesOutputWithConfig` bails out to an empty stylesheet unless
+ // it has styles *and* settings, so a stale `settings` drops the generated
+ // CSS entirely rather than just leaving it out of date.
+ it( 'returns a new userTheme when the styles or the settings change', () => {
+ const settings = { color: { palette: [] } };
+ mockGlobalStylePost( {
+ id: 1,
+ styles: { color: { background: '#fff' } },
+ settings,
+ } );
+
+ const { result, rerender } = renderHook( () => useUserTheme() );
+ const first = result.current.userTheme;
+
+ const updatedStyles = { color: { background: '#000' } };
+ mockGlobalStylePost( { id: 1, styles: updatedStyles, settings } );
+ rerender();
+
+ expect( result.current.userTheme ).not.toBe( first );
+ expect( result.current.userTheme.styles ).toBe( updatedStyles );
+
+ const afterStyles = result.current.userTheme;
+ const updatedSettings = { color: { palette: [ { slug: 'accent' } ] } };
+ mockGlobalStylePost( {
+ id: 1,
+ styles: updatedStyles,
+ settings: updatedSettings,
+ } );
+ rerender();
+
+ expect( result.current.userTheme ).not.toBe( afterStyles );
+ expect( result.current.userTheme.settings ).toBe( updatedSettings );
+ } );
+
+ // The post is absent until it resolves, which is when the editor first
+ // mounts and the stylesheet is generated.
+ it( 'keeps the same userTheme when there is no global styles post', () => {
+ mockGlobalStylePost( null );
+
+ const { result, rerender } = renderHook( () => useUserTheme() );
+ const first = result.current.userTheme;
+
+ rerender();
+
+ expect( result.current.userTheme ).toBe( first );
+ } );
+} );
diff --git a/packages/js/email-editor/src/hooks/use-user-theme.ts b/packages/js/email-editor/src/hooks/use-user-theme.ts
index 665a5190568..0233c95e60a 100644
--- a/packages/js/email-editor/src/hooks/use-user-theme.ts
+++ b/packages/js/email-editor/src/hooks/use-user-theme.ts
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
-import { useCallback } from '@wordpress/element';
+import { useCallback, useMemo } from '@wordpress/element';
import { useSelect, dispatch } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';
@@ -18,6 +18,18 @@ export function useUserTheme() {
};
}, [] );
+ // Consumers use this as a dependency for expensive work such as regenerating
+ // the global styles stylesheet, so the identity must only change when the
+ // styles or settings actually change. Editing styles goes through
+ // `editEntityRecord`, which replaces both values, so the memo still updates.
+ const userTheme = useMemo(
+ () => ( {
+ settings: globalStylePost?.settings,
+ styles: globalStylePost?.styles,
+ } ),
+ [ globalStylePost?.settings, globalStylePost?.styles ]
+ );
+
const updateGlobalStylesPost = useCallback(
( newTheme: EmailTheme ) => {
if ( ! globalStylePost ) {
@@ -37,10 +49,7 @@ export function useUserTheme() {
);
return {
- userTheme: {
- settings: globalStylePost?.settings,
- styles: globalStylePost?.styles,
- },
+ userTheme,
updateUserTheme: updateGlobalStylesPost,
};
}
diff --git a/packages/js/email-editor/src/store/actions.ts b/packages/js/email-editor/src/store/actions.ts
index 79b4169b12a..725c775d6ae 100644
--- a/packages/js/email-editor/src/store/actions.ts
+++ b/packages/js/email-editor/src/store/actions.ts
@@ -9,6 +9,7 @@ import { apiFetch } from '@wordpress/data-controls';
* Internal dependencies
*/
import { storeName, PERSONALIZATION_TAG_ENTITY } from './constants';
+import { getPersonalizationTagsQuery } from './personalization-tags-query';
import {
SendingPreviewStatus,
State,
@@ -55,23 +56,17 @@ export const setEmailPost =
export const invalidatePersonalizationTagsCache =
() =>
async ( { registry } ) => {
- // Get the current post ID to build the exact query params
+ // `invalidateResolution` matches resolver arguments structurally, so this
+ // has to be the same query the selector fetched with — hence the shared
+ // builder rather than a second literal.
const postId = registry.select( storeName ).getEmailPostId();
- const queryParams: Record< string, unknown > = {
- context: 'view',
- per_page: -1,
- };
- if ( postId ) {
- queryParams.post_id = postId;
- }
- // Invalidate the resolution for this specific query
registry
.dispatch( coreDataStore )
.invalidateResolution( 'getEntityRecords', [
PERSONALIZATION_TAG_ENTITY.kind,
PERSONALIZATION_TAG_ENTITY.name,
- queryParams,
+ getPersonalizationTagsQuery( postId ),
] );
};
diff --git a/packages/js/email-editor/src/store/personalization-tags-query.ts b/packages/js/email-editor/src/store/personalization-tags-query.ts
new file mode 100644
index 00000000000..fad3c722371
--- /dev/null
+++ b/packages/js/email-editor/src/store/personalization-tags-query.ts
@@ -0,0 +1,47 @@
+export type PersonalizationTagsQuery = {
+ context: string;
+ per_page: number;
+ post_id?: number | string;
+};
+
+// Keyed by post id, so alternating between posts does not thrash a single slot.
+// Bounded by the number of posts opened in one page load.
+const queryCache = new Map<
+ number | string | undefined,
+ PersonalizationTagsQuery
+>();
+
+/**
+ * Builds the entity query used to fetch personalization tags, returning the
+ * same object for a given post id.
+ *
+ * Two call sites depend on this being one definition. `getPersonalizationTagsList`
+ * runs once per block on every store change and passes the query to
+ * `getEntityRecords`, which forwards it to core-data's `getQueriedItems`; rememo
+ * memoizes that by argument reference, so a fresh object literal misses on every
+ * call and prepends a node to a list only discarded when the entity state
+ * changes. `invalidatePersonalizationTagsCache` passes the same query to
+ * `invalidateResolution`, which matches resolver arguments structurally — if the
+ * two shapes ever drifted, invalidation would silently stop matching rather than
+ * fail loudly.
+ *
+ * @param postId Id of the post being edited, when there is one.
+ * @return Query to pass to `getEntityRecords`.
+ */
+export function getPersonalizationTagsQuery(
+ postId: number | string | undefined
+): PersonalizationTagsQuery {
+ let query = queryCache.get( postId );
+
+ if ( ! query ) {
+ query = {
+ context: 'view',
+ per_page: -1,
+ // Include post_id for context-aware tag filtering (e.g., automation emails)
+ ...( postId ? { post_id: postId } : {} ),
+ };
+ queryCache.set( postId, query );
+ }
+
+ return query;
+}
diff --git a/packages/js/email-editor/src/store/selectors.ts b/packages/js/email-editor/src/store/selectors.ts
index 4a2003f3f7e..86a79b0adb5 100644
--- a/packages/js/email-editor/src/store/selectors.ts
+++ b/packages/js/email-editor/src/store/selectors.ts
@@ -11,6 +11,7 @@ import { serialize, parse, BlockInstance } from '@wordpress/blocks';
* Internal dependencies
*/
import { storeName, PERSONALIZATION_TAG_ENTITY } from './constants';
+import { getPersonalizationTagsQuery } from './personalization-tags-query';
import {
State,
EmailTemplate,
@@ -418,53 +419,81 @@ export function getPreviewState( state: State ): State[ 'preview' ] {
return state.preview;
}
-export const getPersonalizationTagsList = createRegistrySelector(
- ( select ) => () => {
- const postId = select( storeName ).getEmailPostId();
- const queryParams: Record< string, unknown > = {
- context: 'view',
- per_page: -1,
- };
+const EMPTY_PERSONALIZATION_TAGS: PersonalizationTag[] = [];
- // Include post_id for context-aware tag filtering (e.g., automation emails)
- if ( postId ) {
- queryParams.post_id = postId;
- }
-
- const tags = ( select( coreDataStore ).getEntityRecords(
- PERSONALIZATION_TAG_ENTITY.kind,
- PERSONALIZATION_TAG_ENTITY.name,
- queryParams
- ) || [] ) as PersonalizationTag[];
-
- const postType = select( storeName ).getEmailPostType();
+export const getPersonalizationTagsList = createRegistrySelector(
+ ( select ) => {
+ // The block edit filter calls this selector once per block on every store
+ // change, so the filtered list is cached alongside the shared query object
+ // from `getPersonalizationTagsQuery`.
+ //
+ // `createRegistrySelector` resolves this factory once per registry, so the
+ // cache below is scoped to a registry rather than shared globally.
+ //
+ // Keying on `tags` identity is sound because `getQueriedItems` keeps a
+ // value-keyed cache (an `EquivalentKeyMap` per state), so it hands back the
+ // same records array until the entity state itself changes.
+ let listCache: {
+ tags: PersonalizationTag[];
+ postType: string;
+ templatePostTypes: string[] | undefined;
+ result: PersonalizationTag[];
+ } | null = null;
+
+ return () => {
+ const postId = select( storeName ).getEmailPostId();
+
+ const tags = ( select( coreDataStore ).getEntityRecords(
+ PERSONALIZATION_TAG_ENTITY.kind,
+ PERSONALIZATION_TAG_ENTITY.name,
+ getPersonalizationTagsQuery( postId )
+ ) || EMPTY_PERSONALIZATION_TAGS ) as PersonalizationTag[];
+
+ const postType = select( storeName ).getEmailPostType();
+
+ if ( ! postType ) {
+ return tags;
+ }
- if ( ! postType ) {
- return tags;
- }
+ // When postType is template, we filter tags by registered template postTypes.
+ const templatePostTypes =
+ postType === 'wp_template'
+ ? select( storeName ).getCurrentTemplate()?.post_types
+ : undefined;
+
+ if (
+ listCache &&
+ listCache.tags === tags &&
+ listCache.postType === postType &&
+ listCache.templatePostTypes === templatePostTypes
+ ) {
+ return listCache.result;
+ }
- // When postType is template, we filter tags by registered template postTypes.
- if ( postType === 'wp_template' ) {
- const postTemplate = select( storeName ).getCurrentTemplate();
- return tags.filter( ( tag ) => {
- return (
+ const result = tags.filter( ( tag ) => {
+ if (
tag.postTypes === undefined ||
- tag.postTypes.length === 0 ||
- ( Array.isArray( postTemplate.post_types ) &&
- postTemplate.post_types.some( ( pt ) =>
+ tag.postTypes.length === 0
+ ) {
+ return true;
+ }
+
+ if ( postType === 'wp_template' ) {
+ return (
+ Array.isArray( templatePostTypes ) &&
+ templatePostTypes.some( ( pt ) =>
tag.postTypes.includes( pt )
- ) )
- );
+ )
+ );
+ }
+
+ return tag.postTypes.includes( postType );
} );
- }
- return tags.filter( ( tag ) => {
- return (
- tag.postTypes === undefined ||
- tag.postTypes.length === 0 ||
- tag.postTypes.includes( postType )
- );
- } );
+ listCache = { tags, postType, templatePostTypes, result };
+
+ return result;
+ };
}
);
diff --git a/packages/js/email-editor/src/store/test/get-personalization-tags-list.spec.ts b/packages/js/email-editor/src/store/test/get-personalization-tags-list.spec.ts
new file mode 100644
index 00000000000..4413ea83e39
--- /dev/null
+++ b/packages/js/email-editor/src/store/test/get-personalization-tags-list.spec.ts
@@ -0,0 +1,165 @@
+/**
+ * External dependencies
+ */
+import { store as coreDataStore } from '@wordpress/core-data';
+
+/**
+ * Internal dependencies
+ */
+import { getPersonalizationTagsList } from '../selectors';
+import { storeName } from '../constants';
+import { PersonalizationTag } from '../types';
+
+// Importing the selectors module pulls in the whole block editor, which Jest
+// cannot transform. Only the store descriptors are needed to route `select`.
+jest.mock( '@wordpress/core-data', () => ( { store: { name: 'core' } } ) );
+jest.mock( '@wordpress/editor', () => ( { store: { name: 'core/editor' } } ) );
+jest.mock( '@wordpress/preferences', () => ( {
+ store: { name: 'core/preferences' },
+} ) );
+jest.mock( '@wordpress/blocks', () => ( {
+ serialize: jest.fn(),
+ parse: jest.fn(),
+} ) );
+
+const makeTag = ( name: string, postTypes: string[] ): PersonalizationTag => ( {
+ name,
+ token: `[woocommerce/${ name }]`,
+ category: 'Test',
+ attributes: [],
+ valueToInsert: `[woocommerce/${ name }]`,
+ postTypes,
+} );
+
+type Registry = { select: jest.Mock };
+
+/**
+ * `createRegistrySelector` resolves the selector once per registry, so a fresh
+ * registry per test is what gives each test empty caches.
+ *
+ * @param options Store values the selector reads.
+ * @param options.tags Records returned by `getEntityRecords`.
+ * @param options.postType Post type currently being edited.
+ * @param options.template Result of `getCurrentTemplate()`.
+ */
+function buildRegistry( options: {
+ tags: PersonalizationTag[] | null;
+ postType: string | undefined;
+ template?: { post_types: string[] } | null;
+} ) {
+ const { tags, postType, template = null } = options;
+ const getEntityRecords = jest.fn().mockReturnValue( tags );
+
+ const registry: Registry = {
+ select: jest.fn( ( store ) => {
+ if ( store === storeName ) {
+ return {
+ getEmailPostId: () => 23,
+ getEmailPostType: () => postType,
+ getCurrentTemplate: () => template,
+ };
+ }
+ if ( store === coreDataStore ) {
+ return { getEntityRecords };
+ }
+ throw new Error( `Unexpected store: ${ String( store ) }` );
+ } ),
+ };
+
+ (
+ getPersonalizationTagsList as unknown as { registry: Registry }
+ ).registry = registry;
+
+ return { getEntityRecords };
+}
+
+const names = () => getPersonalizationTagsList().map( ( tag ) => tag.name );
+
+describe( 'getPersonalizationTagsList', () => {
+ // Without this, a test that forgot `buildRegistry` would silently reuse the
+ // previous registry along with its warm caches.
+ afterEach( () => {
+ delete (
+ getPersonalizationTagsList as unknown as { registry?: Registry }
+ ).registry;
+ } );
+
+ it( 'filters tags by the edited post type', () => {
+ buildRegistry( {
+ tags: [
+ makeTag( 'a', [ 'woo_email' ] ),
+ makeTag( 'b', [ 'other_type' ] ),
+ makeTag( 'c', [] ),
+ ],
+ postType: 'woo_email',
+ } );
+
+ expect( names() ).toEqual( [ 'a', 'c' ] );
+ } );
+
+ it( 'filters tags by the template post types when editing a template', () => {
+ buildRegistry( {
+ tags: [
+ makeTag( 'a', [ 'woo_email' ] ),
+ makeTag( 'b', [ 'other_type' ] ),
+ makeTag( 'c', [] ),
+ ],
+ postType: 'wp_template',
+ template: { post_types: [ 'other_type' ] },
+ } );
+
+ expect( names() ).toEqual( [ 'b', 'c' ] );
+ } );
+
+ // `getEditedPostTemplate` returns null while a template is unresolved, which
+ // the previous unguarded `postTemplate.post_types` threw a TypeError on.
+ it( 'keeps only untyped tags when the template is unresolved', () => {
+ buildRegistry( {
+ tags: [ makeTag( 'a', [ 'woo_email' ] ), makeTag( 'c', [] ) ],
+ postType: 'wp_template',
+ template: null,
+ } );
+
+ expect( names() ).toEqual( [ 'c' ] );
+ } );
+
+ it( 'returns a referentially stable list across repeated calls', () => {
+ buildRegistry( {
+ tags: [ makeTag( 'a', [ 'woo_email' ] ) ],
+ postType: 'woo_email',
+ } );
+
+ expect( getPersonalizationTagsList() ).toBe(
+ getPersonalizationTagsList()
+ );
+ } );
+
+ // The counterpart to the test above: stable is only correct while the
+ // records are unchanged.
+ it( 'recomputes when the records returned by core-data change', () => {
+ const { getEntityRecords } = buildRegistry( {
+ tags: [ makeTag( 'a', [ 'woo_email' ] ) ],
+ postType: 'woo_email',
+ } );
+ const before = getPersonalizationTagsList();
+
+ getEntityRecords.mockReturnValue( [
+ makeTag( 'a', [ 'woo_email' ] ),
+ makeTag( 'b', [ 'woo_email' ] ),
+ ] );
+
+ expect( getPersonalizationTagsList() ).not.toBe( before );
+ expect( names() ).toEqual( [ 'a', 'b' ] );
+ } );
+
+ // Records are null until the request resolves, which is every call during
+ // initial load — a fresh array each time would defeat the memoization.
+ it( 'returns a stable empty list while the records are unresolved', () => {
+ buildRegistry( { tags: null, postType: 'woo_email' } );
+
+ expect( getPersonalizationTagsList() ).toStrictEqual( [] );
+ expect( getPersonalizationTagsList() ).toBe(
+ getPersonalizationTagsList()
+ );
+ } );
+} );
diff --git a/packages/js/email-editor/src/store/test/personalization-tags-query.spec.ts b/packages/js/email-editor/src/store/test/personalization-tags-query.spec.ts
new file mode 100644
index 00000000000..3fb7b91848d
--- /dev/null
+++ b/packages/js/email-editor/src/store/test/personalization-tags-query.spec.ts
@@ -0,0 +1,39 @@
+/**
+ * Internal dependencies
+ */
+import { getPersonalizationTagsQuery } from '../personalization-tags-query';
+
+describe( 'getPersonalizationTagsQuery', () => {
+ it( 'includes post_id when there is a post id', () => {
+ expect( getPersonalizationTagsQuery( 23 ) ).toStrictEqual( {
+ context: 'view',
+ per_page: -1,
+ post_id: 23,
+ } );
+ } );
+
+ it( 'omits post_id when there is no post id', () => {
+ expect( getPersonalizationTagsQuery( undefined ) ).toStrictEqual( {
+ context: 'view',
+ per_page: -1,
+ } );
+ } );
+
+ // The selector runs once per block on every store change, and core-data
+ // caches the query by argument reference.
+ it( 'returns the same object for the same post id', () => {
+ expect( getPersonalizationTagsQuery( 23 ) ).toBe(
+ getPersonalizationTagsQuery( 23 )
+ );
+ } );
+
+ // A single-slot cache would make alternating post ids miss every time,
+ // which is the problem this builder exists to avoid.
+ it( 'caches each post id independently', () => {
+ const first = getPersonalizationTagsQuery( 23 );
+ const second = getPersonalizationTagsQuery( 24 );
+
+ expect( second ).not.toBe( first );
+ expect( getPersonalizationTagsQuery( 23 ) ).toBe( first );
+ } );
+} );
diff --git a/plugins/woocommerce/changelog/performance-email-editor-canvas-typing-latency b/plugins/woocommerce/changelog/performance-email-editor-canvas-typing-latency
new file mode 100644
index 00000000000..6705eb50fc6
--- /dev/null
+++ b/plugins/woocommerce/changelog/performance-email-editor-canvas-typing-latency
@@ -0,0 +1,4 @@
+Significance: patch
+Type: performance
+
+Email editor: reduce typing latency in the editor canvas.