@@ -12,11 +12,74 @@ import apiFetch from '@wordpress/api-fetch';
1212const CAP_STORE = 'cap/authors' ;
1313const COAUTHORS_ENDPOINT = '/coauthors/v1/coauthors' ;
1414
15- // Module-level cache for guest author avatar URLs, keyed by post ID .
15+ // Module-level cache for guest author avatar URLs, keyed by user_nicename .
1616// Prevents duplicate REST requests when components re-mount or when
1717// multiple avatar blocks in a Query Loop share the same guest authors.
1818const guestAvatarCache = { } ;
1919
20+ // In-flight request promises, keyed by user_nicename.
21+ // Prevents duplicate concurrent requests when multiple block instances
22+ // mount simultaneously (e.g. Query Loop) and all check the cache before
23+ // any fetch has completed.
24+ const inflightRequests = { } ;
25+
26+ /**
27+ * Fetch a single coauthor's avatar URLs, deduplicating concurrent requests.
28+ *
29+ * @param {string } nicename Author nicename (slug).
30+ * @return {Promise } Resolves when the fetch completes (result is stored in guestAvatarCache).
31+ */
32+ function fetchCoauthorAvatar ( nicename ) {
33+ if ( guestAvatarCache [ nicename ] ) {
34+ return Promise . resolve ( ) ;
35+ }
36+ if ( inflightRequests [ nicename ] ) {
37+ return inflightRequests [ nicename ] ;
38+ }
39+ inflightRequests [ nicename ] = apiFetch ( {
40+ path : `${ COAUTHORS_ENDPOINT } /${ encodeURIComponent ( nicename ) } ` ,
41+ } )
42+ . then ( result => {
43+ if ( result ?. avatar_urls ) {
44+ guestAvatarCache [ nicename ] = result . avatar_urls ;
45+ }
46+ } )
47+ . catch ( ( ) => { } ) // Silently skip failed fetches.
48+ . finally ( ( ) => {
49+ delete inflightRequests [ nicename ] ;
50+ } ) ;
51+ return inflightRequests [ nicename ] ;
52+ }
53+
54+ /**
55+ * Reset the module-level guest avatar cache. Exposed for testing only.
56+ */
57+ export function resetGuestAvatarCacheForTests ( ) {
58+ Object . keys ( guestAvatarCache ) . forEach ( key => delete guestAvatarCache [ key ] ) ;
59+ Object . keys ( inflightRequests ) . forEach ( key => delete inflightRequests [ key ] ) ;
60+ }
61+
62+ /**
63+ * Extract user_nicename from an author archive URL.
64+ *
65+ * @param {string } link Author archive URL (e.g. /author/jane/).
66+ * @return {string|undefined } Extracted nicename, or undefined.
67+ */
68+ function extractNicenameFromLink ( link ) {
69+ if ( ! link ) {
70+ return undefined ;
71+ }
72+ const cleaned = link . split ( '?' ) [ 0 ] . split ( '#' ) [ 0 ] . replace ( / \/ $ / , '' ) ;
73+ const segments = cleaned . split ( '/' ) ;
74+ const slug = segments . pop ( ) ;
75+ // Require a parent path segment so root URLs and plain permalinks are rejected.
76+ const parent = segments [ segments . length - 1 ] ;
77+ if ( ! slug || ! parent ) {
78+ return undefined ;
79+ }
80+ return slug ;
81+ }
82+
2083/**
2184 * Hook to get CoAuthors Plus authors from the CAP store or REST API.
2285 *
@@ -74,10 +137,13 @@ export function useCoAuthors( postId, postType = 'post', skip = false ) {
74137
75138 if ( restAuthors && Array . isArray ( restAuthors ) && restAuthors . length > 0 ) {
76139 // Map REST API author objects to our expected format.
140+ // Extract user_nicename from author_link so the avatar
141+ // effect can fetch from the CAP single-author endpoint.
77142 const mappedAuthors = restAuthors . map ( author => ( {
78143 id : author . id ,
79144 display_name : author . display_name ,
80145 author_link : author . author_link ,
146+ user_nicename : extractNicenameFromLink ( author . author_link ) ,
81147 } ) ) ;
82148 return { authors : mappedAuthors , isCapAvailable : true } ;
83149 }
@@ -89,57 +155,63 @@ export function useCoAuthors( postId, postType = 'post', skip = false ) {
89155 [ postId , postType , skip ]
90156 ) ;
91157
92- // Fetch avatar URLs from the CAP REST API for guest authors only .
158+ // Fetch avatar URLs from the CAP REST API for authors that need it .
93159 // The CAP store strips avatar data via formatAuthorData(), so we need
94160 // to fetch the raw REST response to get the avatar URL (especially for
95161 // guest authors whose avatars come from featured images).
96- const [ avatarMap , setAvatarMap ] = useState ( ( ) => guestAvatarCache [ postId ] || { } ) ;
97-
98- // Build a stable key from guest author IDs to avoid re-fetching on every render.
99- const guestAuthorIds = authors
100- . filter ( author => author . isGuest )
101- . map ( author => author . id )
102- . join ( ',' ) ;
162+ //
163+ // Fetches per-author (by nicename) instead of per-post so that avatars
164+ // resolve immediately when a guest author is added — before the post is saved.
165+ //
166+ // For currently-edited post authors, isGuest is explicitly true/false.
167+ // For Query Loop authors, isGuest is undefined (we can't distinguish from
168+ // REST data alone), so we fetch for all of them and let the consumer
169+ // (hooks.js) prefer WP user data when available.
170+ const [ avatarMap , setAvatarMap ] = useState ( { } ) ;
171+
172+ // Build a stable key from author nicenames to control effect re-runs.
173+ // isGuest !== false: includes guest authors (true) and Query Loop authors (undefined),
174+ // but excludes known WP users from the currently-edited post (false).
175+ const authorsNeedingAvatars = authors . filter ( author => author . isGuest !== false && author . user_nicename ) ;
176+ const avatarNicenames = authorsNeedingAvatars . map ( author => author . user_nicename ) . join ( ',' ) ;
103177
104178 useEffect ( ( ) => {
105- if ( skip || ! isCapAvailable || ! guestAuthorIds || ! postId ) {
106- return ;
107- }
108-
109- // Use cached data if available (avoids duplicate requests on re-mount).
110- if ( guestAvatarCache [ postId ] ) {
111- setAvatarMap ( guestAvatarCache [ postId ] ) ;
179+ if ( skip || ! isCapAvailable || ! avatarNicenames ) {
112180 return ;
113181 }
114182
115183 let cancelled = false ;
116- apiFetch ( { path : ` ${ COAUTHORS_ENDPOINT } ?post_id= ${ postId } ` } ) . then ( result => {
117- if ( cancelled || ! Array . isArray ( result ) ) {
184+ Promise . all ( authorsNeedingAvatars . map ( a => fetchCoauthorAvatar ( a . user_nicename ) ) ) . then ( ( ) => {
185+ if ( cancelled ) {
118186 return ;
119187 }
120188 const map = { } ;
121- result . forEach ( item => {
122- if ( item . id && item . avatar_urls ) {
123- map [ item . id ] = item . avatar_urls ;
189+ authorsNeedingAvatars . forEach ( a => {
190+ if ( guestAvatarCache [ a . user_nicename ] ) {
191+ map [ a . id ] = guestAvatarCache [ a . user_nicename ] ;
124192 }
125193 } ) ;
126- if ( Object . keys ( map ) . length ) {
127- guestAvatarCache [ postId ] = map ;
128- setAvatarMap ( map ) ;
129- }
194+ setAvatarMap ( map ) ;
130195 } ) ;
131196
132197 return ( ) => {
133198 cancelled = true ;
134199 } ;
135- } , [ skip , isCapAvailable , guestAuthorIds , postId ] ) ;
200+ } , [ skip , isCapAvailable , avatarNicenames ] ) ;
136201
137202 // Merge avatar URLs into guest authors.
203+ // Check both the async avatarMap (populated by the effect) and the
204+ // synchronous guestAvatarCache (populated by previous fetches) so that
205+ // cached avatars render immediately without waiting for an effect cycle.
138206 const authorsWithAvatars = authors . map ( author => {
139- if ( ! author . isGuest || ! avatarMap [ author . id ] ) {
207+ if ( author . isGuest === false || ! author . user_nicename ) {
208+ return author ;
209+ }
210+ const urls = avatarMap [ author . id ] || guestAvatarCache [ author . user_nicename ] ;
211+ if ( ! urls ) {
140212 return author ;
141213 }
142- return { ...author , avatar_urls : avatarMap [ author . id ] } ;
214+ return { ...author , avatar_urls : urls } ;
143215 } ) ;
144216
145217 return { authors : authorsWithAvatars , isCapAvailable } ;
0 commit comments