Skip to content

Commit bfbe6e1

Browse files
taipeicoderclaude
andcommitted
Navigation Link: Fix "[object Object]" in link preview for untitled entities
For a page with an empty title the entity record's title is `{ raw: '', rendered: '' }`, so `entityRecord?.title?.rendered` is an empty string and the link preview's title computation fell through to `entityRecord?.title` -- the title object itself -- which renders as "[object Object]". Only fall back to `entityRecord.title` when it is a plain string (some entity types expose it that way) so the object is never surfaced. Treat a link with a binding or a resolved record as a known entity: skip the rich URL data fetch for it (including while a bound entity is still resolving) and ignore rich data in the title fallback, so a title scraped for a prior URL can't leak after switching the link to an untitled entity. An empty entity title is valid data, not a cue to scrape the page. Before WordPress#77170 this rendered an object as a React child and crashed the block ("This block has encountered an error and cannot be previewed"). WordPress#77170 wrapped the preview title in stripHTML, which coerced the object to the harmless "[object Object]" string; this addresses the underlying cause. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 23f80e8 commit bfbe6e1

2 files changed

Lines changed: 88 additions & 5 deletions

File tree

packages/block-library/src/navigation-link/shared/test/use-link-preview.test.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,79 @@ describe( 'useLinkPreview', () => {
500500
expect( result.current.title ).toBe( 'Test Category' );
501501
} );
502502

503+
it( 'should not surface the title object for an empty title', () => {
504+
const { result } = renderHook( () =>
505+
useLinkPreview( {
506+
url: 'https://example.com/page',
507+
// Untitled page: an empty `title.rendered` object.
508+
entityRecord: { title: { raw: '', rendered: '' } },
509+
type: 'page',
510+
hasBinding: false,
511+
isEntityAvailable: true,
512+
} )
513+
);
514+
515+
// Falls back to the URL, not the title object (or "[object Object]").
516+
expect( result.current.title ).toBe( 'https://example.com/page' );
517+
// A known entity is not scraped for a title.
518+
expect( mockUseRemoteUrlData ).toHaveBeenCalledWith( null );
519+
} );
520+
521+
it( 'should not fetch rich URL data while a bound entity is resolving', () => {
522+
const { result } = renderHook( () =>
523+
useLinkPreview( {
524+
url: 'https://example.com/page',
525+
entityRecord: null,
526+
type: 'page',
527+
hasBinding: true,
528+
isEntityAvailable: true,
529+
} )
530+
);
531+
532+
expect( result.current.title ).toBe( 'https://example.com/page' );
533+
expect( mockUseRemoteUrlData ).toHaveBeenCalledWith( null );
534+
} );
535+
536+
it( 'should not leak stale rich data when a link switches from a URL to an entity', () => {
537+
// Model useRemoteUrlData retaining its last result when later called
538+
// with a disabled (null) URL.
539+
let retained = null;
540+
mockUseRemoteUrlData.mockImplementation( ( fetchUrl ) => {
541+
if ( fetchUrl ) {
542+
retained = { title: 'External Site Title' };
543+
}
544+
return { richData: retained };
545+
} );
546+
547+
const { result, rerender } = renderHook(
548+
( props ) => useLinkPreview( props ),
549+
{
550+
initialProps: {
551+
url: 'https://example.com/page',
552+
entityRecord: null,
553+
type: 'custom',
554+
hasBinding: false,
555+
isEntityAvailable: false,
556+
},
557+
}
558+
);
559+
560+
// A custom/external link uses the scraped title.
561+
expect( result.current.title ).toBe( 'External Site Title' );
562+
563+
// Switch the same link to an untitled bound entity.
564+
rerender( {
565+
url: 'https://example.com/page',
566+
entityRecord: { title: { raw: '', rendered: '' } },
567+
type: 'page',
568+
hasBinding: true,
569+
isEntityAvailable: true,
570+
} );
571+
572+
// The retained scraped title must not leak; fall back to the URL.
573+
expect( result.current.title ).toBe( 'https://example.com/page' );
574+
} );
575+
503576
it( 'should use entityRecord.name for taxonomy terms', () => {
504577
const { result } = renderHook( () =>
505578
useLinkPreview( {

packages/block-library/src/navigation-link/shared/use-link-preview.js

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -225,11 +225,19 @@ export function useLinkPreview( {
225225

226226
const title =
227227
entityRecord?.title?.rendered ||
228-
entityRecord?.title ||
228+
// Only use a string `title`, so an empty `title.rendered` doesn't
229+
// surface the { raw, rendered } object ("[object Object]").
230+
( typeof entityRecord?.title === 'string' && entityRecord.title ) ||
229231
entityRecord?.name;
230232

231-
// Fetch rich URL data if we don't have a title. Internal links should have passed a title.
232-
const { richData } = useRemoteUrlData( title ? null : url );
233+
// A link with a binding or a resolved record is a known entity, whose
234+
// (possibly empty) title is authoritative. `hasBinding` is truthy from the
235+
// first render, so this also covers a bound entity that is still resolving.
236+
const isEntityLink = !! ( entityRecord || hasBinding );
237+
238+
// Fetch rich URL data only for links without a title or a known entity; an
239+
// empty entity title is valid data, not a cue to scrape the page for one.
240+
const { richData } = useRemoteUrlData( title || isEntityLink ? null : url );
233241

234242
// Compute display URL and external flag
235243
const { displayUrl, isExternal } = computeDisplayUrl( {
@@ -275,9 +283,11 @@ export function useLinkPreview( {
275283
isEntityAvailable,
276284
} );
277285

278-
// Get display title - use provided title, fallback to rich data, or URL
286+
// Get display title - use provided title, fallback to rich data, or URL.
287+
// Ignore rich data for entity links (it may be stale from a prior URL) so
288+
// their fallback is the URL, not a scraped title.
279289
const displayTitle = url
280-
? title || richData?.title || safeDecodeURI( url )
290+
? title || ( ! isEntityLink && richData?.title ) || safeDecodeURI( url )
281291
: __( 'Add link' );
282292

283293
return {

0 commit comments

Comments
 (0)