diff --git a/projects/packages/publicize/_inc/components/customize-and-preview/preview-section/index.tsx b/projects/packages/publicize/_inc/components/customize-and-preview/preview-section/index.tsx
index 495cef6191fe..f9a5f004d4ee 100644
--- a/projects/packages/publicize/_inc/components/customize-and-preview/preview-section/index.tsx
+++ b/projects/packages/publicize/_inc/components/customize-and-preview/preview-section/index.tsx
@@ -1,14 +1,82 @@
import { Icon, VisuallyHidden } from '@wordpress/components';
import { __, _x } from '@wordpress/i18n';
import { info } from '@wordpress/icons';
-import { Connection } from '../../../social-store/types';
+import clsx from 'clsx';
+import { useConnectionPreviewData } from '../../../hooks/use-connection-preview-data';
import { PostPreview } from './post-preview';
import styles from './styles.module.scss';
+import type { Connection } from '../../../social-store/types';
type PreviewSectionProps = {
connection: Connection;
};
+/**
+ * Preview loading placeholder.
+ *
+ * @return Preview loading placeholder.
+ */
+function PreviewSkeleton() {
+ return (
+
diff --git a/projects/packages/publicize/_inc/components/customize-and-preview/preview-section/post-preview.tsx b/projects/packages/publicize/_inc/components/customize-and-preview/preview-section/post-preview.tsx
index c8e66159845a..79bb5a502cd9 100644
--- a/projects/packages/publicize/_inc/components/customize-and-preview/preview-section/post-preview.tsx
+++ b/projects/packages/publicize/_inc/components/customize-and-preview/preview-section/post-preview.tsx
@@ -15,12 +15,13 @@ import { useSelect } from '@wordpress/data';
import { useMemo } from '@wordpress/element';
import { decodeEntities } from '@wordpress/html-entities';
import { __ } from '@wordpress/i18n';
-import { useConnectionPreviewData } from '../../../hooks/use-connection-preview-data';
-import { Connection } from '../../../social-store/types';
import { InstagramNoMediaNotice } from '../../form/instagram-no-media-notice';
+import type { ConnectionPreviewData } from '../../../hooks/use-connection-preview-data';
+import type { Connection } from '../../../social-store/types';
export type PostPreviewProps = {
connection: Connection;
+ previewData: ConnectionPreviewData;
};
/**
@@ -44,7 +45,7 @@ function getCombinedText( title: string, excerpt: string ): string {
*
* @return - Post preview component.
*/
-export function PostPreview( { connection }: PostPreviewProps ) {
+export function PostPreview( { connection, previewData }: PostPreviewProps ) {
const user = useMemo(
() => ( {
displayName: connection.display_name,
@@ -54,8 +55,7 @@ export function PostPreview( { connection }: PostPreviewProps ) {
[ connection ]
);
- const { image, media, title, description, url, excerpt, message } =
- useConnectionPreviewData( connection );
+ const { image, media, title, description, url, excerpt, message } = previewData;
const commonProps = useMemo(
() => ( {
diff --git a/projects/packages/publicize/_inc/components/customize-and-preview/preview-section/styles.module.scss b/projects/packages/publicize/_inc/components/customize-and-preview/preview-section/styles.module.scss
index 24d587e55ec2..ca4d02c8efc5 100644
--- a/projects/packages/publicize/_inc/components/customize-and-preview/preview-section/styles.module.scss
+++ b/projects/packages/publicize/_inc/components/customize-and-preview/preview-section/styles.module.scss
@@ -1,5 +1,20 @@
@use "@automattic/jetpack-base-styles/gutenberg-base-styles" as gb;
+@keyframes preview-loading-pulse {
+
+ 0% {
+ opacity: 0.56;
+ }
+
+ 50% {
+ opacity: 1;
+ }
+
+ 100% {
+ opacity: 0.56;
+ }
+}
+
.preview-section {
background: gb.$gray-100;
padding: 1rem;
@@ -24,4 +39,62 @@
align-items: center;
justify-content: center;
}
+
+ .preview-skeleton {
+ inline-size: min(100%, 420px);
+ padding: 1rem;
+ border: 1px solid gb.$gray-200;
+ border-radius: 8px;
+ background: gb.$white;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ }
+
+ .preview-skeleton-header {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ }
+
+ .preview-skeleton-avatar,
+ .preview-skeleton-line,
+ .preview-skeleton-media {
+ background: gb.$gray-200;
+ animation: preview-loading-pulse 1.5s ease-in-out infinite;
+ }
+
+ .preview-skeleton-avatar {
+ inline-size: 40px;
+ block-size: 40px;
+ border-radius: 50%;
+ flex: 0 0 auto;
+ }
+
+ .preview-skeleton-lines,
+ .preview-skeleton-copy {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ inline-size: 100%;
+ }
+
+ .preview-skeleton-line {
+ inline-size: 100%;
+ block-size: 12px;
+ border-radius: 999px;
+ }
+
+ .preview-skeleton-line-medium {
+ inline-size: 72%;
+ }
+
+ .preview-skeleton-line-short {
+ inline-size: 44%;
+ }
+
+ .preview-skeleton-media {
+ aspect-ratio: 16 / 9;
+ border-radius: 4px;
+ }
}
diff --git a/projects/packages/publicize/_inc/components/customize-and-preview/preview-section/test/index.test.tsx b/projects/packages/publicize/_inc/components/customize-and-preview/preview-section/test/index.test.tsx
new file mode 100644
index 000000000000..33f0805096b4
--- /dev/null
+++ b/projects/packages/publicize/_inc/components/customize-and-preview/preview-section/test/index.test.tsx
@@ -0,0 +1,94 @@
+import { render, screen } from '@testing-library/react';
+import { PreviewSection } from '../';
+import { useConnectionPreviewData } from '../../../../hooks/use-connection-preview-data';
+import type { ConnectionPreviewData } from '../../../../hooks/use-connection-preview-data';
+import type { Connection } from '../../../../social-store/types';
+
+jest.mock( '../../../../hooks/use-connection-preview-data', () => ( {
+ useConnectionPreviewData: jest.fn(),
+} ) );
+
+jest.mock( '../post-preview', () => ( {
+ PostPreview: ( { previewData }: { previewData: ConnectionPreviewData } ) => (
+
{ previewData.message }
+ ),
+} ) );
+
+jest.mock( '../styles.module.scss', () => ( {
+ 'inactive-preview': 'inactive-preview',
+ 'preview-section': 'preview-section',
+ 'preview-skeleton': 'preview-skeleton',
+ 'preview-skeleton-avatar': 'preview-skeleton-avatar',
+ 'preview-skeleton-copy': 'preview-skeleton-copy',
+ 'preview-skeleton-header': 'preview-skeleton-header',
+ 'preview-skeleton-line': 'preview-skeleton-line',
+ 'preview-skeleton-line-medium': 'preview-skeleton-line-medium',
+ 'preview-skeleton-line-short': 'preview-skeleton-line-short',
+ 'preview-skeleton-lines': 'preview-skeleton-lines',
+ 'preview-skeleton-media': 'preview-skeleton-media',
+ 'preview-wrapper': 'preview-wrapper',
+} ) );
+
+const mockUseConnectionPreviewData = useConnectionPreviewData as jest.MockedFunction<
+ typeof useConnectionPreviewData
+>;
+
+const connection: Connection = {
+ connection_id: '123',
+ display_name: 'Example Account',
+ enabled: true,
+ external_handle: '@example',
+ external_id: 'external-id',
+ profile_link: 'https://example.com',
+ profile_picture: 'https://example.com/avatar.jpg',
+ service_label: 'Facebook',
+ service_name: 'facebook',
+ shared: false,
+ status: 'ok',
+ wpcom_user_id: 1,
+};
+
+const previewData: ConnectionPreviewData = {
+ description: 'Description',
+ excerpt: 'Excerpt',
+ image: 'https://example.com/image.jpg',
+ isLoading: false,
+ media: [],
+ message: 'Rendered message',
+ siteTitle: 'Example Site',
+ title: 'Title',
+ url: 'https://example.com/post',
+};
+
+describe( 'PreviewSection', () => {
+ beforeEach( () => {
+ mockUseConnectionPreviewData.mockReturnValue( previewData );
+ } );
+
+ afterEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ it( 'renders the post preview for enabled connections when preview data is ready', () => {
+ render(
);
+
+ expect( screen.getByTestId( 'post-preview' ) ).toHaveTextContent( previewData.message );
+ expect( screen.queryByRole( 'status' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'renders one preview skeleton while preview data is loading', () => {
+ mockUseConnectionPreviewData.mockReturnValue( { ...previewData, isLoading: true } );
+
+ render(
);
+
+ expect( screen.getByRole( 'status' ) ).toHaveTextContent( 'Loading post preview.' );
+ expect( screen.queryByTestId( 'post-preview' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'does not read preview data for inactive connections', () => {
+ render(
);
+
+ expect( screen.getByText( "The post won't be shared to this account." ) ).toBeInTheDocument();
+ expect( mockUseConnectionPreviewData ).not.toHaveBeenCalled();
+ } );
+} );
diff --git a/projects/packages/publicize/_inc/hooks/use-connection-preview-data/index.ts b/projects/packages/publicize/_inc/hooks/use-connection-preview-data/index.ts
index d90e4c4bc524..3baa72b6f24f 100644
--- a/projects/packages/publicize/_inc/hooks/use-connection-preview-data/index.ts
+++ b/projects/packages/publicize/_inc/hooks/use-connection-preview-data/index.ts
@@ -3,7 +3,6 @@ import { useSelect } from '@wordpress/data';
import { store as editorStore } from '@wordpress/editor';
import { useMemo } from 'react';
import { store as socialStore } from '../../social-store';
-import { Connection } from '../../social-store/types';
import { features } from '../../utils';
import useMediaDetails from '../use-media-details';
import { usePerNetworkCustomization } from '../use-per-network-customization';
@@ -12,7 +11,13 @@ import { useRenderMessageItems } from '../use-render-message-items';
import useSigPreview from '../use-sig-preview';
import useSocialMediaMessage from '../use-social-media-message';
import { useSocialPreviewPostData } from '../use-social-preview-post-data';
-import { PostPreviewData } from '../use-social-preview-post-data/types';
+import type { Connection } from '../../social-store/types';
+import type { PostPreviewData } from '../use-social-preview-post-data/types';
+
+export type ConnectionPreviewData = PostPreviewData & {
+ message: string;
+ isLoading: boolean;
+};
/**
* Returns the post data needed for the preview of a specific connection.
@@ -20,7 +25,7 @@ import { PostPreviewData } from '../use-social-preview-post-data/types';
* @param {Connection} connection - The connection.
* @return The post data.
*/
-export function useConnectionPreviewData( connection: Connection ) {
+export function useConnectionPreviewData( connection: Connection ): ConnectionPreviewData {
const { isEnabled: usingPerNetworkCustomization } = usePerNetworkCustomization();
const { mediaSource: globalMediaSource } = usePostMeta();
@@ -86,47 +91,63 @@ export function useConnectionPreviewData( connection: Connection ) {
const templatesEnabled = siteHasFeature( features.MESSAGE_TEMPLATES );
const items = useRenderMessageItems();
+ const hasConnectionMessage = connection.message !== undefined && connection.message !== '';
+ let baseMessage: string;
+ if ( isPerNetworkMode ) {
+ if ( hasConnectionMessage ) {
+ baseMessage = connection.message ?? '';
+ } else if ( templatesEnabled && connection.template ) {
+ baseMessage = connection.template;
+ } else {
+ baseMessage = globalMessage;
+ }
+ } else {
+ baseMessage = globalMessage;
+ }
+ baseMessage = baseMessage.trim();
+ const currentRenderItem = items.find( item => item.id === connection.connection_id );
- const rendered = useSelect(
+ const { rendered, isLoadingRendered } = useSelect(
select => {
if ( ! templatesEnabled || ! postId ) {
- return null;
+ return { rendered: null, isLoadingRendered: false };
}
- // Calling getRenderedMessages via select() is what triggers the resolver
- // (and the POST). Picking the per-connection slice off the returned batch
- // keeps the call explicit instead of routing through a derived selector.
- const batch = select( socialStore ).getRenderedMessages( postId, items );
- return batch?.[ connection.connection_id ]?.rendered_message ?? null;
+ // Read from the cache-only selector so this hook does not trigger requests.
+ // Fetches are driven centrally by `useDriveRenderedMessagesFetch`.
+ const social = select( socialStore );
+ const batch = social.getCachedRenderedMessages( postId, items );
+
+ return {
+ rendered: batch?.[ connection.connection_id ]?.rendered_message ?? null,
+ isLoadingRendered: social.isLoadingRenderedMessages( postId, items ),
+ };
},
[ templatesEnabled, postId, items, connection.connection_id ]
);
+ // True while the user has typed but the debounced items array hasn't caught
+ // up yet — the store doesn't see edits until items are committed, so the
+ // consumer has to compute this itself.
+ const isDebouncingRenderedMessage =
+ templatesEnabled &&
+ baseMessage.length > 0 &&
+ currentRenderItem?.message !== undefined &&
+ currentRenderItem.message !== baseMessage;
+
return useMemo( () => {
const useRendered = templatesEnabled && typeof rendered === 'string';
- const hasConnectionMessage = connection.message !== undefined && connection.message !== '';
- let baseMessage: string;
- if ( isPerNetworkMode ) {
- if ( hasConnectionMessage ) {
- baseMessage = connection.message ?? '';
- } else if ( templatesEnabled && connection.template ) {
- baseMessage = connection.template;
- } else {
- baseMessage = globalMessage;
- }
- } else {
- baseMessage = globalMessage;
- }
+ const isLoading = templatesEnabled && ( isDebouncingRenderedMessage || isLoadingRendered );
return {
...postData,
- message: useRendered ? rendered : baseMessage.trim(),
+ message: useRendered ? rendered : baseMessage,
media,
+ isLoading,
};
}, [
- connection.message,
- connection.template,
- globalMessage,
- isPerNetworkMode,
+ baseMessage,
+ isDebouncingRenderedMessage,
+ isLoadingRendered,
media,
postData,
rendered,
diff --git a/projects/packages/publicize/_inc/hooks/use-connection-preview-data/test/index.test.ts b/projects/packages/publicize/_inc/hooks/use-connection-preview-data/test/index.test.ts
index a0c8e04b7dca..97a6710e9c42 100644
--- a/projects/packages/publicize/_inc/hooks/use-connection-preview-data/test/index.test.ts
+++ b/projects/packages/publicize/_inc/hooks/use-connection-preview-data/test/index.test.ts
@@ -117,16 +117,23 @@ const defaultPostData = {
* Mock the chained useSelect calls inside the hook so each one returns its expected
* shape: postId, featuredImageId, then the rendered slice.
*
- * @param opts - Per-test overrides.
- * @param opts.postId - Post id returned to the editor-store useSelect.
- * @param opts.rendered - String returned for the rendered slice, or null to signal "no slice yet".
+ * @param opts - Per-test overrides.
+ * @param opts.postId - Post id returned to the editor-store useSelect.
+ * @param opts.rendered - String returned for the rendered slice, or null to signal "no slice yet".
+ * @param opts.isLoadingRendered - Whether the rendered-messages cache slot is currently in-flight.
*/
-function mockSelectCalls( opts: { postId?: number; rendered?: string | null } = {} ) {
- const { postId = 42, rendered = null } = opts;
+function mockSelectCalls(
+ opts: {
+ postId?: number;
+ rendered?: string | null;
+ isLoadingRendered?: boolean;
+ } = {}
+) {
+ const { postId = 42, rendered = null, isLoadingRendered = false } = opts;
mockUseSelect
.mockReturnValueOnce( postId )
.mockReturnValueOnce( 0 )
- .mockReturnValueOnce( rendered );
+ .mockReturnValueOnce( { rendered, isLoadingRendered } );
}
describe( 'useConnectionPreviewData', () => {
@@ -161,6 +168,7 @@ describe( 'useConnectionPreviewData', () => {
expect( result.current ).toEqual( {
...defaultPostData,
+ isLoading: false,
message: 'Global message',
} );
} );
@@ -306,15 +314,89 @@ describe( 'useConnectionPreviewData', () => {
expect( result.current.message ).toBe( 'Global message' );
} );
+ it( 'shows loading while the live template message is waiting for debounce', () => {
+ mockSelectCalls( { rendered: null } );
+ mockSiteHasFeature.mockReturnValue( true );
+ mockUseRenderMessageItems.mockReturnValue( [
+ {
+ id: '123',
+ network: 'tumblr',
+ message: 'Old template',
+ is_social_post: false,
+ },
+ ] );
+ mockUseSocialMediaMessage.mockReturnValue( {
+ message: 'New template {excerpt}',
+ updateMessage: jest.fn(),
+ maxLength: 280,
+ } );
+
+ const connection = createMockConnection();
+ const { result } = renderHook( () => useConnectionPreviewData( connection ) );
+
+ expect( result.current.isLoading ).toBe( true );
+ } );
+
+ it( 'does not show loading in global mode when the render item matches the global message', () => {
+ mockSelectCalls( { rendered: 'Rendered global template' } );
+ mockSiteHasFeature.mockReturnValue( true );
+ mockUseRenderMessageItems.mockReturnValue( [
+ {
+ id: '123',
+ network: 'tumblr',
+ message: 'Global message',
+ is_social_post: false,
+ },
+ ] );
+
+ const connection = createMockConnection( { message: 'Per-network message' } );
+ const { result } = renderHook( () => useConnectionPreviewData( connection ) );
+
+ expect( result.current.message ).toBe( 'Rendered global template' );
+ expect( result.current.isLoading ).toBe( false );
+ } );
+
+ it( 'keeps loading after debounce until the render request finishes', () => {
+ mockSelectCalls( { rendered: null, isLoadingRendered: true } );
+ mockSiteHasFeature.mockReturnValue( true );
+ mockUseRenderMessageItems.mockReturnValue( [
+ {
+ id: '123',
+ network: 'tumblr',
+ message: 'New template {excerpt}',
+ is_social_post: false,
+ },
+ ] );
+ mockUseSocialMediaMessage.mockReturnValue( {
+ message: 'New template {excerpt}',
+ updateMessage: jest.fn(),
+ maxLength: 280,
+ } );
+
+ const connection = createMockConnection();
+ const { result } = renderHook( () => useConnectionPreviewData( connection ) );
+
+ expect( result.current.isLoading ).toBe( true );
+ } );
+
it( 'ignores rendered message when MESSAGE_TEMPLATES feature is off', () => {
- mockSelectCalls( { rendered: 'Should not be used' } );
+ mockSelectCalls( { rendered: 'Should not be used', isLoadingRendered: true } );
mockSiteHasFeature.mockImplementation(
( feature: string ) => feature !== 'social-message-templates'
);
+ mockUseRenderMessageItems.mockReturnValue( [
+ {
+ id: '123',
+ network: 'tumblr',
+ message: 'Different debounced template',
+ is_social_post: false,
+ },
+ ] );
const connection = createMockConnection();
const { result } = renderHook( () => useConnectionPreviewData( connection ) );
expect( result.current.message ).toBe( 'Global message' );
+ expect( result.current.isLoading ).toBe( false );
} );
} );
diff --git a/projects/packages/publicize/_inc/hooks/use-render-message-items/index.ts b/projects/packages/publicize/_inc/hooks/use-render-message-items/index.ts
index 44a3f397f507..c01376b26875 100644
--- a/projects/packages/publicize/_inc/hooks/use-render-message-items/index.ts
+++ b/projects/packages/publicize/_inc/hooks/use-render-message-items/index.ts
@@ -1,5 +1,5 @@
import { siteHasFeature } from '@automattic/jetpack-script-data';
-import { useSelect } from '@wordpress/data';
+import { useRegistry, useSelect } from '@wordpress/data';
import { store as editorStore } from '@wordpress/editor';
import { useEffect, useMemo, useRef, useState } from '@wordpress/element';
import { store as socialStore } from '../../social-store';
@@ -119,11 +119,20 @@ export function useRenderMessageItems(): RenderItem[] {
const items = useMemo< RenderItem[] >( () => {
return connections.map( connection => {
const hasConnectionMessage = connection.message !== undefined && connection.message !== '';
+ // Mirror the rule in `useConnectionPreviewData` exactly — per-connection
+ // message and template only apply in per-network mode. If they leak into
+ // global mode here, the consumer's `baseMessage` (globalMessage) won't
+ // match this items array's message and `isDebouncingRenderedMessage` stays
+ // stuck true after the user toggles per-network → global.
let raw: string;
- if ( hasConnectionMessage ) {
- raw = connection.message ?? '';
- } else if ( ctx.isPerNetworkMode ) {
- raw = connection.template ?? globalMessage ?? '';
+ if ( ctx.isPerNetworkMode ) {
+ if ( hasConnectionMessage ) {
+ raw = connection.message ?? '';
+ } else if ( templatesEnabled && connection.template ) {
+ raw = connection.template;
+ } else {
+ raw = globalMessage ?? '';
+ }
} else {
raw = globalMessage ?? '';
}
@@ -134,7 +143,7 @@ export function useRenderMessageItems(): RenderItem[] {
is_social_post: connectionHasMedia( connection, ctx ),
};
} );
- }, [ connections, globalMessage, ctx ] );
+ }, [ connections, globalMessage, ctx, templatesEnabled ] );
return useDebouncedItems( items );
}
@@ -151,20 +160,23 @@ export function useRenderMessageItems(): RenderItem[] {
*/
export function useDriveRenderedMessagesFetch(): void {
const items = useRenderMessageItems();
+ const registry = useRegistry();
const postId = useSelect(
select => select( editorStore ).getCurrentPostId() as number | undefined,
[]
);
- useSelect(
- select => {
- if ( ! postId || items.length === 0 ) {
- return null;
- }
- return select( socialStore ).getRenderedMessages( postId, items );
- },
- [ postId, items ]
- );
+ useEffect( () => {
+ if ( ! postId || items.length === 0 ) {
+ return;
+ }
+
+ void registry
+ .resolveSelect( socialStore )
+ .getRenderedMessages( postId, items )
+ // Errors are intentionally swallowed to preserve existing UI behavior.
+ .catch( () => {} );
+ }, [ items, postId, registry ] );
}
/**
@@ -188,12 +200,17 @@ function hashMessages( items: RenderItem[] ): string {
*/
function useDebouncedItems( items: RenderItem[] ): RenderItem[] {
const [ debounced, setDebounced ] = useState( items );
- const prevMessagesRef = useRef( hashMessages( items ) );
+ const committedMessagesRef = useRef( hashMessages( items ) );
+
+ useEffect( () => {
+ // Track the last committed (emitted) message fingerprint, not the latest input.
+ // This avoids flushing pending message edits early on unrelated re-renders.
+ committedMessagesRef.current = hashMessages( debounced );
+ }, [ debounced ] );
useEffect( () => {
const currentMessages = hashMessages( items );
- const hasMessageChange = currentMessages !== prevMessagesRef.current;
- prevMessagesRef.current = currentMessages;
+ const hasMessageChange = currentMessages !== committedMessagesRef.current;
if ( ! hasMessageChange ) {
setDebounced( items );
diff --git a/projects/packages/publicize/_inc/hooks/use-render-message-items/test/index.test.ts b/projects/packages/publicize/_inc/hooks/use-render-message-items/test/index.test.ts
index e0f65e4c61eb..0f737af001ca 100644
--- a/projects/packages/publicize/_inc/hooks/use-render-message-items/test/index.test.ts
+++ b/projects/packages/publicize/_inc/hooks/use-render-message-items/test/index.test.ts
@@ -134,31 +134,33 @@ describe( 'useRenderMessageItems', () => {
expect( result.current ).toEqual( [] );
} );
- it( 'builds one item per enabled connection, keyed by connection_id', () => {
+ it( 'builds one item per connection in global mode, keyed by connection_id', () => {
mockUseSelect.mockReturnValue( [
- conn( { connection_id: 'a', service_name: 'x', message: 'A' } ),
+ conn( { connection_id: 'a', service_name: 'x' } ),
conn( { connection_id: 'b', service_name: 'facebook' } ),
] );
const { result } = renderHook( () => useRenderMessageItems() );
expect( result.current ).toEqual( [
- { id: 'a', network: 'x', message: 'A', is_social_post: false },
+ { id: 'a', network: 'x', message: 'Global', is_social_post: false },
{ id: 'b', network: 'facebook', message: 'Global', is_social_post: false },
] );
} );
- it( 'falls back to the global message when a connection has none', () => {
- mockUseSelect.mockReturnValue( [ conn( { connection_id: 'a', message: undefined } ) ] );
- mockUseSocialMediaMessage.mockReturnValue( {
- message: 'Global only',
- updateMessage: jest.fn(),
- maxLength: 280,
- } );
+ it( 'uses connection messages in per-network mode', () => {
+ mockUsePerNetworkCustomization.mockReturnValue( { isEnabled: true, toggle: jest.fn() } );
+ mockUseSelect.mockReturnValue( [
+ conn( { connection_id: 'a', service_name: 'x', message: 'A' } ),
+ conn( { connection_id: 'b', service_name: 'facebook' } ),
+ ] );
const { result } = renderHook( () => useRenderMessageItems() );
- expect( result.current[ 0 ].message ).toBe( 'Global only' );
+ expect( result.current ).toEqual( [
+ { id: 'a', network: 'x', message: 'A', is_social_post: false },
+ { id: 'b', network: 'facebook', message: 'Global', is_social_post: false },
+ ] );
} );
it( 'sets is_social_post=true in global mode when there is global media', () => {
@@ -196,7 +198,12 @@ describe( 'useRenderMessageItems', () => {
} );
it( 'debounces 1500ms when a message string changes', () => {
- mockUseSelect.mockReturnValue( [ conn( { message: 'first' } ) ] );
+ mockUseSelect.mockReturnValue( [ conn() ] );
+ mockUseSocialMediaMessage.mockReturnValue( {
+ message: 'first',
+ updateMessage: jest.fn(),
+ maxLength: 280,
+ } );
const { result, rerender } = renderHook( () => useRenderMessageItems() );
@@ -206,7 +213,11 @@ describe( 'useRenderMessageItems', () => {
} );
expect( result.current[ 0 ].message ).toBe( 'first' );
- mockUseSelect.mockReturnValue( [ conn( { message: 'second' } ) ] );
+ mockUseSocialMediaMessage.mockReturnValue( {
+ message: 'second',
+ updateMessage: jest.fn(),
+ maxLength: 280,
+ } );
rerender();
// Still showing the previous value — change is in-flight.
@@ -219,8 +230,54 @@ describe( 'useRenderMessageItems', () => {
expect( result.current[ 0 ].message ).toBe( 'second' );
} );
+ it( 'does not flush a pending message change on unrelated re-renders', () => {
+ mockUseSelect.mockReturnValue( [ conn() ] );
+ mockUseSocialMediaMessage.mockReturnValue( {
+ message: 'first',
+ updateMessage: jest.fn(),
+ maxLength: 280,
+ } );
+
+ const { result, rerender } = renderHook( () => useRenderMessageItems() );
+
+ act( () => {
+ jest.runOnlyPendingTimers();
+ } );
+ expect( result.current[ 0 ].message ).toBe( 'first' );
+
+ mockUseSocialMediaMessage.mockReturnValue( {
+ message: 'second',
+ updateMessage: jest.fn(),
+ maxLength: 280,
+ } );
+ rerender();
+ expect( result.current[ 0 ].message ).toBe( 'first' );
+
+ mockUseSocialPreviewPostData.mockReturnValue( {
+ media: [ { url: 'https://example.com/m.jpg', type: 'image/jpeg' } ],
+ } );
+ rerender();
+
+ expect( result.current[ 0 ].message ).toBe( 'first' );
+
+ act( () => {
+ jest.advanceTimersByTime( 1499 );
+ } );
+ expect( result.current[ 0 ].message ).toBe( 'first' );
+
+ act( () => {
+ jest.advanceTimersByTime( 1 );
+ } );
+ expect( result.current[ 0 ].message ).toBe( 'second' );
+ } );
+
it( 'updates immediately when only non-message inputs change', () => {
- mockUseSelect.mockReturnValue( [ conn( { message: 'same' } ) ] );
+ mockUseSelect.mockReturnValue( [ conn() ] );
+ mockUseSocialMediaMessage.mockReturnValue( {
+ message: 'same',
+ updateMessage: jest.fn(),
+ maxLength: 280,
+ } );
const { result, rerender } = renderHook( () => useRenderMessageItems() );
@@ -232,7 +289,6 @@ describe( 'useRenderMessageItems', () => {
mockUseSocialPreviewPostData.mockReturnValue( {
media: [ { url: 'https://example.com/m.jpg', type: 'image/jpeg' } ],
} );
- mockUseSelect.mockReturnValue( [ conn( { message: 'same' } ) ] );
rerender();
// Effects run synchronously inside act() with fake timers — no advance needed.
diff --git a/projects/packages/publicize/_inc/social-store/actions/constants.ts b/projects/packages/publicize/_inc/social-store/actions/constants.ts
index 21043f08483b..728d4fc046d8 100644
--- a/projects/packages/publicize/_inc/social-store/actions/constants.ts
+++ b/projects/packages/publicize/_inc/social-store/actions/constants.ts
@@ -48,4 +48,8 @@ export const SET_IS_SCHEDULING_SHARES = 'SET_IS_SCHEDULING_SHARES' as const;
export const SET_SHOW_SINGLE_X_NOTICE = 'SET_SHOW_SINGLE_X_NOTICE' as const;
+export const START_RENDERING_MESSAGES = 'START_RENDERING_MESSAGES' as const;
+
export const RECEIVE_RENDERED_MESSAGES = 'RECEIVE_RENDERED_MESSAGES' as const;
+
+export const FINISH_RENDERING_MESSAGES = 'FINISH_RENDERING_MESSAGES' as const;
diff --git a/projects/packages/publicize/_inc/social-store/actions/rendered-messages.ts b/projects/packages/publicize/_inc/social-store/actions/rendered-messages.ts
index 5b902fecc733..854dfdaa3300 100644
--- a/projects/packages/publicize/_inc/social-store/actions/rendered-messages.ts
+++ b/projects/packages/publicize/_inc/social-store/actions/rendered-messages.ts
@@ -1,17 +1,60 @@
-import { RECEIVE_RENDERED_MESSAGES } from './constants';
+import { renderMessagesCacheKey, type RenderItem } from '../../utils/render-messages';
+import {
+ FINISH_RENDERING_MESSAGES,
+ RECEIVE_RENDERED_MESSAGES,
+ START_RENDERING_MESSAGES,
+} from './constants';
import type { RenderedMessageBatch } from '../types';
/**
- * Store the rendered batch for a given (post, items-hash) cache key.
+ * Mark a (postId, items) batch as in-flight. Dispatched by the resolver before
+ * the apiFetch fires. Any prior `items` are preserved so the consumer can keep
+ * showing the previous render while the new one resolves.
*
- * @param cacheKey - `${postId}|${hashRenderItems(items)}` — cache slot.
- * @param batch - Map of connection_id → result for this batch.
+ * @param postId - Post being previewed.
+ * @param items - The render items.
* @return Action object.
*/
-export function receiveRenderedMessages( cacheKey: string, batch: RenderedMessageBatch ) {
+export function startRenderingMessages( postId: number, items: RenderItem[] ) {
+ return {
+ type: START_RENDERING_MESSAGES,
+ cacheKey: renderMessagesCacheKey( postId, items ),
+ } as const;
+}
+
+/**
+ * Store the rendered batch for a given (postId, items) cache key. Implicitly
+ * clears the loading flag for that key.
+ *
+ * @param postId - Post being previewed.
+ * @param items - The render items.
+ * @param batch - Map of connection_id → result for this batch.
+ * @return Action object.
+ */
+export function receiveRenderedMessages(
+ postId: number,
+ items: RenderItem[],
+ batch: RenderedMessageBatch
+) {
return {
type: RECEIVE_RENDERED_MESSAGES,
- cacheKey,
+ cacheKey: renderMessagesCacheKey( postId, items ),
batch,
} as const;
}
+
+/**
+ * Clear the loading flag for a (postId, items) batch without touching `items`.
+ * Used in the resolver's error path so the consumer keeps whatever it had
+ * (preserves the "no flash on failure" behavior).
+ *
+ * @param postId - Post being previewed.
+ * @param items - The render items.
+ * @return Action object.
+ */
+export function finishRenderingMessages( postId: number, items: RenderItem[] ) {
+ return {
+ type: FINISH_RENDERING_MESSAGES,
+ cacheKey: renderMessagesCacheKey( postId, items ),
+ } as const;
+}
diff --git a/projects/packages/publicize/_inc/social-store/reducer/rendered-messages.ts b/projects/packages/publicize/_inc/social-store/reducer/rendered-messages.ts
index 3abcebe2085e..c901f149052e 100644
--- a/projects/packages/publicize/_inc/social-store/reducer/rendered-messages.ts
+++ b/projects/packages/publicize/_inc/social-store/reducer/rendered-messages.ts
@@ -1,24 +1,58 @@
-import { RECEIVE_RENDERED_MESSAGES } from '../actions/constants';
-import { receiveRenderedMessages } from '../actions/rendered-messages';
+import {
+ FINISH_RENDERING_MESSAGES,
+ RECEIVE_RENDERED_MESSAGES,
+ START_RENDERING_MESSAGES,
+} from '../actions/constants';
+import {
+ finishRenderingMessages,
+ receiveRenderedMessages,
+ startRenderingMessages,
+} from '../actions/rendered-messages';
import { RenderedMessages } from '../types';
-type Action = ReturnType< typeof receiveRenderedMessages > | { type: 'default' };
+type Action =
+ | ReturnType< typeof startRenderingMessages >
+ | ReturnType< typeof receiveRenderedMessages >
+ | ReturnType< typeof finishRenderingMessages >
+ | { type: 'default' };
/**
* Rendered-messages reducer. State is keyed by `${postId}|${itemsHash}` so each
* unique render-input batch lives in its own slot — reverting to a previously
* seen items shape reads back the original response cleanly.
*
+ * Each entry tracks `isLoading` alongside its `items`, so consumers can read a
+ * single status flag instead of stitching together resolver state.
+ *
* @param state - Slice state.
* @param action - Action object.
* @return Updated slice state.
*/
export function renderedMessages( state: RenderedMessages = {}, action: Action ): RenderedMessages {
switch ( action.type ) {
+ case START_RENDERING_MESSAGES:
+ return {
+ ...state,
+ [ action.cacheKey ]: {
+ ...state[ action.cacheKey ],
+ isLoading: true,
+ },
+ };
case RECEIVE_RENDERED_MESSAGES:
return {
...state,
- [ action.cacheKey ]: action.batch,
+ [ action.cacheKey ]: {
+ isLoading: false,
+ items: action.batch,
+ },
+ };
+ case FINISH_RENDERING_MESSAGES:
+ return {
+ ...state,
+ [ action.cacheKey ]: {
+ ...state[ action.cacheKey ],
+ isLoading: false,
+ },
};
default:
return state;
diff --git a/projects/packages/publicize/_inc/social-store/resolvers.ts b/projects/packages/publicize/_inc/social-store/resolvers.ts
index 41732ba2bef1..67326ac28053 100644
--- a/projects/packages/publicize/_inc/social-store/resolvers.ts
+++ b/projects/packages/publicize/_inc/social-store/resolvers.ts
@@ -1,9 +1,13 @@
import apiFetch from '@wordpress/api-fetch';
import { store as editorStore } from '@wordpress/editor';
-import { hashRenderItems, type RenderItem, type RenderResult } from '../utils/render-messages';
+import { type RenderItem, type RenderResult } from '../utils/render-messages';
import { normalizeShareStatus } from '../utils/share-status';
import { setConnections } from './actions/connection-data';
-import { receiveRenderedMessages } from './actions/rendered-messages';
+import {
+ finishRenderingMessages,
+ receiveRenderedMessages,
+ startRenderingMessages,
+} from './actions/rendered-messages';
import { fetchPostShareStatus, receivePostShareStaus } from './actions/share-status';
import { PostShareStatus, RenderedMessageBatch } from './types';
@@ -90,7 +94,7 @@ export function getRenderedMessages( postId: number, items: RenderItem[] ) {
return;
}
- const cacheKey = `${ postId }|${ hashRenderItems( items ) }`;
+ dispatch( startRenderingMessages( postId, items ) );
try {
const records = await apiFetch< RenderResult[] >( {
@@ -111,10 +115,11 @@ export function getRenderedMessages( postId: number, items: RenderItem[] ) {
batch[ record.id ] = slot;
}
- dispatch( receiveRenderedMessages( cacheKey, batch ) );
+ dispatch( receiveRenderedMessages( postId, items, batch ) );
} catch {
- // Keep the previous batch on error — preserves the "no flash on failure"
- // behavior callers expect.
+ // Keep the previous batch on error — clear loading without overwriting
+ // items so the consumer keeps showing whatever it had.
+ dispatch( finishRenderingMessages( postId, items ) );
}
};
}
diff --git a/projects/packages/publicize/_inc/social-store/selectors/rendered-messages.ts b/projects/packages/publicize/_inc/social-store/selectors/rendered-messages.ts
index eb796a2a8260..dbffbb2eae3a 100644
--- a/projects/packages/publicize/_inc/social-store/selectors/rendered-messages.ts
+++ b/projects/packages/publicize/_inc/social-store/selectors/rendered-messages.ts
@@ -1,28 +1,35 @@
-import { hashRenderItems, type RenderItem } from '../../utils/render-messages';
+import { renderMessagesCacheKey, type RenderItem } from '../../utils/render-messages';
import type { RenderedMessageBatch, SocialStoreState } from '../types';
/**
- * Compute the cache slot key for a given (postId, items) batch.
+ * The whole batch for a given (postId, items). Pairs with the
+ * `getRenderedMessages` resolver, which fires the POST on first read with these
+ * args and stores the response under the same cache key.
*
+ * @param state - State object.
* @param postId - Post being previewed.
* @param items - The render items.
- * @return Cache key string.
+ * @return The batch (id → result), or undefined if the resolver hasn't filled it yet.
*/
-function cacheKeyFor( postId: number, items: RenderItem[] ): string {
- return `${ postId }|${ hashRenderItems( items ) }`;
+export function getRenderedMessages(
+ state: SocialStoreState,
+ postId: number,
+ items: RenderItem[]
+): RenderedMessageBatch | undefined {
+ return getCachedRenderedMessages( state, postId, items );
}
/**
- * The whole batch for a given (postId, items). Pairs with the
- * `getRenderedMessages` resolver, which fires the POST on first read with these
- * args and stores the response under the same cache key.
+ * Read the rendered-messages cache without triggering the resolver.
+ *
+ * Use this selector in UI consumers that should not initiate network fetches.
*
* @param state - State object.
* @param postId - Post being previewed.
* @param items - The render items.
- * @return The batch (id → result), or undefined if the resolver hasn't filled it yet.
+ * @return The cached batch (id → result), or undefined if absent.
*/
-export function getRenderedMessages(
+export function getCachedRenderedMessages(
state: SocialStoreState,
postId: number,
items: RenderItem[]
@@ -30,5 +37,32 @@ export function getRenderedMessages(
if ( ! postId || items.length === 0 ) {
return undefined;
}
- return state.renderedMessages?.[ cacheKeyFor( postId, items ) ];
+ return state.renderedMessages?.[ renderMessagesCacheKey( postId, items ) ]?.items;
+}
+
+/**
+ * Whether the batch for these items is currently being fetched. Returns true
+ * either when the resolver has explicitly marked the slot loading, or when no
+ * entry exists yet — that "no entry yet" window covers the gap between an
+ * items-array commit and the resolver dispatching `start`, so the preview
+ * doesn't flash the raw baseMessage in between.
+ *
+ * The resolver's `finish` action keeps the entry around with `isLoading: false`
+ * on error, so the failure path still falls back to baseMessage cleanly.
+ *
+ * @param state - State object.
+ * @param postId - Post being previewed.
+ * @param items - The render items.
+ * @return Loading flag for the matching cache slot.
+ */
+export function isLoadingRenderedMessages(
+ state: SocialStoreState,
+ postId: number,
+ items: RenderItem[]
+): boolean {
+ if ( ! postId || items.length === 0 ) {
+ return false;
+ }
+ const entry = state.renderedMessages?.[ renderMessagesCacheKey( postId, items ) ];
+ return entry === undefined || entry.isLoading;
}
diff --git a/projects/packages/publicize/_inc/social-store/selectors/test/rendered-messages.test.ts b/projects/packages/publicize/_inc/social-store/selectors/test/rendered-messages.test.ts
index ad337e988640..74a4da676134 100644
--- a/projects/packages/publicize/_inc/social-store/selectors/test/rendered-messages.test.ts
+++ b/projects/packages/publicize/_inc/social-store/selectors/test/rendered-messages.test.ts
@@ -1,5 +1,5 @@
-import { hashRenderItems, type RenderItem } from '../../../utils/render-messages';
-import { getRenderedMessages } from '../rendered-messages';
+import { renderMessagesCacheKey, type RenderItem } from '../../../utils/render-messages';
+import { getRenderedMessages, isLoadingRenderedMessages } from '../rendered-messages';
import type { RenderedMessages, SocialStoreState } from '../../types';
const item = ( id: string, message = '' ): RenderItem => ( {
@@ -23,10 +23,11 @@ describe( 'getRenderedMessages', () => {
it( 'reads the batch stored under the cache key for these items', () => {
const items = [ item( 'a' ), item( 'b' ) ];
- const cacheKey = `42|${ hashRenderItems( items ) }`;
const batch = { a: { rendered_message: 'A' }, b: { rendered_message: 'B' } };
- const state = stateWith( { [ cacheKey ]: batch } );
+ const state = stateWith( {
+ [ renderMessagesCacheKey( 42, items ) ]: { isLoading: false, items: batch },
+ } );
expect( getRenderedMessages( state, 42, items ) ).toBe( batch );
} );
@@ -38,8 +39,14 @@ describe( 'getRenderedMessages', () => {
const itemsB = [ item( 'a', 'B' ) ];
const state = stateWith( {
- [ `42|${ hashRenderItems( itemsA ) }` ]: { a: { rendered_message: 'rendered-A' } },
- [ `42|${ hashRenderItems( itemsB ) }` ]: { a: { rendered_message: 'rendered-B' } },
+ [ renderMessagesCacheKey( 42, itemsA ) ]: {
+ isLoading: false,
+ items: { a: { rendered_message: 'rendered-A' } },
+ },
+ [ renderMessagesCacheKey( 42, itemsB ) ]: {
+ isLoading: false,
+ items: { a: { rendered_message: 'rendered-B' } },
+ },
} );
expect( getRenderedMessages( state, 42, itemsA )?.a.rendered_message ).toBe( 'rendered-A' );
@@ -48,3 +55,36 @@ describe( 'getRenderedMessages', () => {
expect( getRenderedMessages( state, 42, itemsA )?.a.rendered_message ).toBe( 'rendered-A' );
} );
} );
+
+describe( 'isLoadingRenderedMessages', () => {
+ it( 'returns false when postId is missing', () => {
+ expect( isLoadingRenderedMessages( stateWith( {} ), 0, [ item( 'a' ) ] ) ).toBe( false );
+ } );
+
+ it( 'returns false when items is empty', () => {
+ expect( isLoadingRenderedMessages( stateWith( {} ), 42, [] ) ).toBe( false );
+ } );
+
+ it( 'returns true when no entry exists yet — closes the flash window before the resolver dispatches start', () => {
+ expect( isLoadingRenderedMessages( stateWith( {} ), 42, [ item( 'a' ) ] ) ).toBe( true );
+ } );
+
+ it( 'returns true while the resolver has marked the cache slot as loading', () => {
+ const items = [ item( 'a' ) ];
+ const state = stateWith( {
+ [ renderMessagesCacheKey( 42, items ) ]: { isLoading: true },
+ } );
+ expect( isLoadingRenderedMessages( state, 42, items ) ).toBe( true );
+ } );
+
+ it( 'returns false once the resolver clears loading', () => {
+ const items = [ item( 'a' ) ];
+ const state = stateWith( {
+ [ renderMessagesCacheKey( 42, items ) ]: {
+ isLoading: false,
+ items: { a: { rendered_message: 'A' } },
+ },
+ } );
+ expect( isLoadingRenderedMessages( state, 42, items ) ).toBe( false );
+ } );
+} );
diff --git a/projects/packages/publicize/_inc/social-store/types.ts b/projects/packages/publicize/_inc/social-store/types.ts
index e72e7601f18d..bda5ae3336f6 100644
--- a/projects/packages/publicize/_inc/social-store/types.ts
+++ b/projects/packages/publicize/_inc/social-store/types.ts
@@ -106,10 +106,11 @@ export type UnifiedModalState = {
export type RenderCount = { [ Key in 'social-preview' | 'edit-template' ]?: number };
/**
- * One rendered batch, indexed by per-connection result. The batch is keyed in
- * `RenderedMessages` by `${postId}|${hashRenderItems(items)}` so each unique
- * input shape gets its own slot — reverting to a previously-seen shape reads
- * back the original response without refetching.
+ * One rendered batch, indexed by per-connection result. The batch is wrapped in
+ * a `RenderedMessageEntry` and keyed in `RenderedMessages` by
+ * `${postId}|${hashRenderItems(items)}` so each unique input shape gets its
+ * own slot — reverting to a previously-seen shape reads back the original
+ * response without refetching.
*/
export type RenderedMessageBatch = {
[ ConnectionId: string ]: {
@@ -118,8 +119,18 @@ export type RenderedMessageBatch = {
};
};
+/**
+ * Per-cache-key entry. `isLoading` is set true by the resolver before the fetch
+ * fires and cleared on either success (with `items` populated) or failure
+ * (preserving any prior `items`).
+ */
+export type RenderedMessageEntry = {
+ isLoading: boolean;
+ items?: RenderedMessageBatch;
+};
+
export type RenderedMessages = {
- [ Key: string ]: RenderedMessageBatch;
+ [ Key: string ]: RenderedMessageEntry;
};
export type SocialStoreState = {
diff --git a/projects/packages/publicize/_inc/utils/render-messages.ts b/projects/packages/publicize/_inc/utils/render-messages.ts
index 76c9a52bbf38..6e6c2631c1f6 100644
--- a/projects/packages/publicize/_inc/utils/render-messages.ts
+++ b/projects/packages/publicize/_inc/utils/render-messages.ts
@@ -36,3 +36,16 @@ export function hashRenderItems( items: RenderItem[] ): string {
items.map( i => [ i.id, i.network, i.message ?? '', Boolean( i.is_social_post ) ] )
);
}
+
+/**
+ * Compute the slice cache key for a given (postId, items) batch. Shared between
+ * action creators, the resolver, and selectors so they all agree on which slot
+ * a request maps to.
+ *
+ * @param postId - Post being previewed.
+ * @param items - The render items.
+ * @return Cache key string of the form `${postId}|${hashRenderItems(items)}`.
+ */
+export function renderMessagesCacheKey( postId: number, items: RenderItem[] ): string {
+ return `${ postId }|${ hashRenderItems( items ) }`;
+}
diff --git a/projects/packages/publicize/changelog/social-475-skeleton b/projects/packages/publicize/changelog/social-475-skeleton
new file mode 100644
index 000000000000..efab79abeeb5
--- /dev/null
+++ b/projects/packages/publicize/changelog/social-475-skeleton
@@ -0,0 +1,4 @@
+Significance: patch
+Type: changed
+
+Social: Show a preview skeleton while message templates render.