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 ( +
+ { __( 'Loading post preview.', 'jetpack-publicize-pkg' ) } +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} + +/** + * Enabled preview area. + * + * @param {PreviewSectionProps} props - The component props. + * @return Enabled preview area. + */ +function EnabledPreview( { connection }: PreviewSectionProps ) { + const previewData = useConnectionPreviewData( connection ); + + return ( + <> + + { _x( 'Preview', 'Noun: Post preview section heading', 'jetpack-publicize-pkg' ) } + +
+ { previewData.isLoading ? ( + + ) : ( + + ) } +
+ + ); +} + /** * Preview section component. * @@ -22,14 +90,7 @@ export function PreviewSection( { connection }: PreviewSectionProps ) { className={ styles[ 'preview-section' ] } > { connection.enabled ? ( - <> - - { _x( 'Preview', 'Noun: Post preview section heading', 'jetpack-publicize-pkg' ) } - -
- -
- + ) : (
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.