Hello @manish-sharma
Here are our relatedContent code:
Presenter (index):
import React, { useMemo } from 'react';
import RelatedContent from '@/components/RelatedContent';
import type { ArticleCardProps } from '@/components/ArticleCard/consts';
import type {
RelatedContentModelType,
RelatedContentPostCount,
RelatedContentCardItem,
} from '@/builder-presenters/RelatedContent/consts';
import { type BasePresenterProps, builderPresenter } from '@/utils/builder/builderPresenter';
import type { HeadingWithEyebrowProps } from '@/molecules/HeadingWithEyebrow/consts';
import SectionNew from '@/molecules/SectionNew';
import type { SectionNewGap, SectionNewPosition, SectionNewProps } from '@/molecules/SectionNew/consts';
import type { SectionPresenterProps } from '@/builder-presenters/Section';
import { getRelatedContentAsyncProps, transformToArticleCard } from '@/builder-presenters/RelatedContent/utils';
// Raw section props from Builder.io (derived from SectionPresenterProps, omitting BasePresenterProps, and required fields)
// Position and gap are made optional for this use case
// Align is filtered out from the Builder inputs
type RawSectionProps = Omit<SectionPresenterProps, keyof BasePresenterProps | 'position' | 'gap' | 'align'> & {
position?: SectionNewPosition;
gap?: SectionNewGap;
};
// Transform raw Builder.io section props to SectionNew props
// This is needed because Builder uses 'image' but SectionNew expects 'backgroundImage'
const transformSectionProps = (section?: RawSectionProps): SectionNewProps | undefined => {
if (!section) return undefined;
const { image, useImageBackground, useVideoBackground, video, useGradient, ...rest } = section;
return {
...rest,
// Map Builder's 'image' to SectionNew's 'backgroundImage'
...(useImageBackground && image ? { backgroundImage: image } : {}),
// Map Builder's 'video' to SectionNew's 'videoSources'
...(useVideoBackground && video
? {
videoSources: {
small: video.srcSmall || '',
medium: video.srcMedium || '',
large: video.srcLarge || '',
},
}
: {}),
// Only include gradient if useGradient is true
...(useGradient && rest.gradient ? { gradient: rest.gradient } : {}),
} as SectionNewProps;
};
export type RelatedContentAsyncProps = {
modelType: RelatedContentModelType;
postCount: RelatedContentPostCount;
data: ArticleCardProps[];
heading?: HeadingWithEyebrowProps;
section?: RawSectionProps;
};
export type RelatedContentPresenterProps = BasePresenterProps &
RelatedContentAsyncProps & {
enableManualSelection: boolean;
cards?: RelatedContentCardItem[];
};
type TransformedProps =
| {
enableManualSelection: false;
data: ArticleCardProps[];
heading?: HeadingWithEyebrowProps;
section?: SectionNewProps;
}
| {
enableManualSelection: true;
heading?: HeadingWithEyebrowProps;
section?: SectionNewProps;
cards: RelatedContentCardItem[];
};
export const transformRelatedContentProps = ({
enableManualSelection,
data,
builderBlock,
heading,
section,
...props
}: RelatedContentPresenterProps): TransformedProps => {
// Transform section props from Builder.io format to SectionNew format
const transformedSection = transformSectionProps(section);
if (!enableManualSelection) {
return {
enableManualSelection: false,
data,
heading,
section: transformedSection,
};
}
// Extract cards directly from builderBlock to avoid async props interference
// This ensures we always get the latest cards from Builder, even when async props cause re-renders
const cards = (builderBlock?.component?.options?.cards as RelatedContentCardItem[] | undefined) || props.cards || [];
return {
enableManualSelection: true,
heading,
section: transformedSection,
cards,
};
};
export default builderPresenter(
props => {
const { enableManualSelection, section, heading, ...rest } = props;
// Remove data and cards from rest to prevent them from being spread onto SectionNew
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { data, cards, ...sectionProps } = rest as typeof rest & { data?: unknown; cards?: unknown };
if (!enableManualSelection) {
return (
<SectionNew {...section} {...sectionProps}>
<RelatedContent data={props.data} heading={heading} />
</SectionNew>
);
}
// Use useMemo to stabilize the manualCards array and prevent unnecessary re-renders
// Use a ref to preserve cards across renders to prevent them from disappearing
const previousCardsRef = React.useRef<ArticleCardProps[]>([]);
// Create a stable dependency string based on card IDs instead of array reference
// This ensures memoization only recalculates when card content actually changes
// We compute the string inline in the dependency array since React compares values, not references
const cardIdsString =
props.cards
?.map(card => card?.card?.id)
.filter((id): id is string => Boolean(id))
.join(',') ?? '';
const manualCards: ArticleCardProps[] = useMemo(() => {
if (!props.cards || props.cards.length === 0) {
// If we have previous cards, keep them to prevent disappearing
// This handles the case where props.cards temporarily becomes empty during re-renders
if (previousCardsRef.current.length > 0) {
return previousCardsRef.current;
}
return [];
}
// Check if cards have valid content - if not, use previous cards
const cardsWithValidContent = props.cards.filter(card => card?.card?.value);
const validCardsCount = cardsWithValidContent.length;
// If we have previous cards but current cards are invalid, keep previous cards
if (validCardsCount === 0 && previousCardsRef.current.length > 0) {
return previousCardsRef.current;
}
const transformed = props.cards
.map(card => {
const content = card?.card?.value;
if (!content) {
return null;
}
// Extract model type from BuilderReference if available
const modelType = card?.card?.model;
return transformToArticleCard(content, modelType);
})
.filter((item): item is ArticleCardProps => item !== null);
// If transformation resulted in fewer cards than we had before, and we have previous cards, use previous
if (transformed.length === 0 && previousCardsRef.current.length > 0) {
return previousCardsRef.current;
}
// Update the ref with the new cards only if we got valid cards
if (transformed.length > 0) {
previousCardsRef.current = transformed;
}
return transformed.length > 0 ? transformed : previousCardsRef.current;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cardIdsString]);
return (
<SectionNew {...section} {...sectionProps}>
<RelatedContent data={manualCards} heading={heading} />
</SectionNew>
);
},
transformRelatedContentProps,
{ includeSection: false, fetchAsyncProps: getRelatedContentAsyncProps },
);
return (
<RelatedContentWrapper ref={ref} {...rest}>
<HeadingWithEyebrow type="heading2" align="center" {...headingProps}>
{headingChildren}
</HeadingWithEyebrow>
<CardWrapper>
{data ? (
data.map(blog => {
return <StyledArticleCard key={`RelatedContent_${blog.title}`} variant="boxed" {...blog} />;
})
) : (
<Spinner size="xl" />
)}
</CardWrapper>
</RelatedContentWrapper>
);
};
export default memo(forwardRef(RelatedContent));
Present (Builder config)
import type { Component, Input } from '@builder.io/sdk';
import { HeadingInputs } from '@/builder-presenters/Heading/heading.builder';
import { RELATED_CONTENT_MODEL_TYPES, RELATED_CONTENT_POST_COUNTS } from '@/builder-presenters/RelatedContent/consts';
import { SectionInputs } from '@/builder-presenters/Section/section.builder';
const relatedContentInputs: Input[] = [
{
name: 'heading',
friendlyName: 'Heading',
type: 'object',
subFields: HeadingInputs.filter(field => !['align'].includes(field.name) && field.name !== 'eyebrow'),
defaultValue: {
type: 'heading2',
children: 'You might also like',
},
},
{
name: 'enableManualSelection',
friendlyName: 'Enable manual selection',
helperText:
'Enable manual selection of content. By default, the content is automatically selected based on the model type, and fetched from the latest published content.',
type: 'boolean',
defaultValue: false,
},
{
name: 'postCount',
friendlyName: 'Post count',
helperText: 'Please refresh the page after changing the post count to see the change in the preview.',
type: 'enum',
enum: RELATED_CONTENT_POST_COUNTS.map(count => ({ label: count.toString(), value: count })),
defaultValue: RELATED_CONTENT_POST_COUNTS[0],
showIf: "options.get('enableManualSelection') === false",
},
{
name: 'modelType',
friendlyName: 'Post model type',
helperText: 'Please refresh the page after changing the model type to see the change in the preview.',
type: 'string',
enum: Object.entries(RELATED_CONTENT_MODEL_TYPES).map(([key, value]) => ({ label: value, value: key })),
defaultValue: 'blog-detail',
},
// Builder doesn't seem to allow dynamic fields based on the model type, so we have to use a list of cards for each model type.
// I submitted an inquiry to Builder.io to see if this is possible, but no response yet.
//
{
name: 'cards',
type: 'list',
showIf: "options.get('modelType') === 'blog-detail' && options.get('enableManualSelection') === true",
helperText: 'You can manually select the cards to display in the Related Content.',
subFields: [
{
name: 'card',
type: 'reference',
friendlyName: 'Blog Card',
model: 'blog-detail',
meta: {
title: 'Blog Detail',
},
},
],
},
{
name: 'cards',
type: 'list',
showIf: "options.get('modelType') === 'event-detail' && options.get('enableManualSelection') === true",
helperText: 'You can manually select the cards to display in the Related Content.',
subFields: [
{
name: 'card',
friendlyName: 'Event Card',
type: 'reference',
model: 'event-detail',
meta: {
title: 'Event Detail',
},
},
],
},
{
name: 'cards',
type: 'list',
showIf: "options.get('modelType') === 'customer-detail' && options.get('enableManualSelection') === true",
helperText: 'You can manually select the cards to display in the Related Content.',
subFields: [
{
name: 'card',
friendlyName: 'Customer Card',
type: 'reference',
model: 'customer-detail',
meta: {
title: 'Customer Detail',
},
},
],
},
{
name: 'cards',
type: 'list',
showIf: "options.get('modelType') === 'ebook-detail' && options.get('enableManualSelection') === true",
helperText: 'You can manually select the cards to display in the Related Content.',
subFields: [
{
name: 'card',
friendlyName: 'Ebook Card',
type: 'reference',
model: 'ebook-detail',
meta: {
title: 'Ebook Detail',
},
},
],
},
{
name: 'cards',
type: 'list',
showIf: "options.get('modelType') === 'resources-infographic' && options.get('enableManualSelection') === true",
helperText: 'You can manually select the cards to display in the Related Content.',
subFields: [
{
name: 'card',
friendlyName: 'Infographic Card',
type: 'reference',
model: 'resources-infographic',
meta: {
title: 'Infographic Detail',
},
},
],
},
{
name: 'cards',
type: 'list',
showIf: "options.get('modelType') === 'resources-product-demo' && options.get('enableManualSelection') === true",
helperText: 'You can manually select the cards to display in the Related Content.',
subFields: [
{
name: 'card',
friendlyName: 'Product Demo Card',
type: 'reference',
model: 'resources-product-demo',
meta: {
title: 'Product Demo Detail',
},
},
],
},
{
name: 'cards',
friendlyName: 'List of Cards',
type: 'list',
showIf: "options.get('modelType') === 'resources-webinar' && options.get('enableManualSelection') === true",
helperText: 'You can manually select the cards to display in the Related Content.',
subFields: [
{
name: 'card',
friendlyName: 'Webinar Card',
type: 'reference',
model: 'resources-webinar',
meta: {
title: 'Webinar Detail',
},
},
],
},
{
name: 'section',
type: 'object',
subFields: SectionInputs.filter(field => field.name !== 'align'),
advanced: true,
},
];
export const RelatedContentComponent: Component = {
name: 'Related Content',
noWrap: true,
image:
'https://cdn.builder.io/api/v1/image/assets%2F1d8ecee591ac4358befb8fe998100548%2F82b8f6b8aa224e1aade0684a398a3133',
inputs: relatedContentInputs,
canHaveChildren: true,
};
Component:
import type { ForwardRefRenderFunction } from 'react';
import { forwardRef, memo } from 'react';
import HeadingWithEyebrow from '@/molecules/HeadingWithEyebrow';
import type { RelatedContentProps } from '@/components/RelatedContent/consts';
import { CardWrapper, RelatedContentWrapper, StyledArticleCard } from '@/components/RelatedContent/styled';
import { Spinner } from '@/molecules/Spinner';
const RelatedContent: ForwardRefRenderFunction<HTMLDivElement, RelatedContentProps> = (
{ heading, data, ...rest }: RelatedContentProps,
ref,
) => {
const { children: headingChildren = 'You might also like', ...headingProps } = heading || {};
return (
<RelatedContentWrapper ref={ref} {...rest}>
<HeadingWithEyebrow type="heading2" align="center" {...headingProps}>
{headingChildren}
</HeadingWithEyebrow>
<CardWrapper>
{data ? (
data.map(blog => {
return <StyledArticleCard key={`RelatedContent_${blog.title}`} variant="boxed" {...blog} />;
})
) : (
<Spinner size="xl" />
)}
</CardWrapper>
</RelatedContentWrapper>
);
};
export default memo(forwardRef(RelatedContent));