diff --git a/packages/shared/src/components/feeds/hero/FeedHero.spec.tsx b/packages/shared/src/components/feeds/hero/FeedHero.spec.tsx new file mode 100644 index 0000000000..f64d6d0449 --- /dev/null +++ b/packages/shared/src/components/feeds/hero/FeedHero.spec.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import nock from 'nock'; +import { render, screen, waitFor } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import type { NextRouter } from 'next/router'; +import { useRouter } from 'next/router'; +import basePost from '../../../../__tests__/fixture/post'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import { mockGraphQL } from '../../../../__tests__/helpers/graphql'; +import { + FEED_HERO_QUERY, + supportedTypesForPrivateSources, +} from '../../../graphql/feed'; +import { FeedHero } from './FeedHero'; +import { feedHeroShape } from './feedHeroShape'; +import { useFeedHeroAd } from './useFeedHeroAd'; + +jest.mock('next/router', () => ({ + useRouter: jest.fn(), +})); + +jest.mock('./useFeedHeroAd', () => ({ + useFeedHeroAd: jest.fn(), +})); + +const posts = ['First hero post', 'Second hero post'].map((title, index) => ({ + ...basePost, + id: `hero-post-${index}`, + title, +})); + +const highlights = ['First headline', 'Second headline', 'Third headline'].map( + (headline, index) => ({ + id: `hero-highlight-${index}`, + channel: 'agents', + headline, + highlightedAt: '2026-04-05T09:00:00.000Z', + post: { + id: `hero-post-${index}`, + commentsPermalink: `/posts/hero-post-${index}`, + }, + }), +); + +beforeEach(() => { + jest.clearAllMocks(); + nock.cleanAll(); + jest + .mocked(useRouter) + .mockImplementation(() => ({ pathname: '/' } as unknown as NextRouter)); + jest.mocked(useFeedHeroAd).mockReturnValue({ + ad: undefined, + placement: 'none', + shape: feedHeroShape(3), + }); +}); + +const mockHero = (data: { + posts: typeof posts; + highlights: typeof highlights; +}) => + mockGraphQL({ + request: { + query: FEED_HERO_QUERY, + variables: { + loggedIn: false, + supportedTypes: supportedTypesForPrivateSources, + }, + }, + result: { data: { feedHero: data } }, + }); + +const renderComponent = () => + render( + + + , + ); + +describe('FeedHero', () => { + it('should render its cards and its rail from the one query', async () => { + mockHero({ posts, highlights }); + + renderComponent(); + + await waitFor(() => + expect( + screen.getByRole('heading', { name: 'First hero post' }), + ).toBeInTheDocument(), + ); + expect(screen.getByText('Second headline')).toBeInTheDocument(); + expect(screen.getByText('Third headline')).toBeInTheDocument(); + }); + + it('should drop only the lead story from a stacked rail', async () => { + jest.mocked(useFeedHeroAd).mockReturnValue({ + ad: undefined, + placement: 'none', + // Stacked: the lead story is already a card above the list. + shape: feedHeroShape(1), + }); + mockHero({ posts, highlights }); + + renderComponent(); + + await waitFor(() => + expect( + screen.getByRole('heading', { name: 'First hero post' }), + ).toBeInTheDocument(), + ); + // Matched on the post behind it, not on its position in the list. + expect(screen.queryByText('First headline')).not.toBeInTheDocument(); + expect(screen.getByText('Second headline')).toBeInTheDocument(); + expect(screen.getByText('Third headline')).toBeInTheDocument(); + }); + + it('should keep a rail headline whose post is not the lead card', async () => { + jest.mocked(useFeedHeroAd).mockReturnValue({ + ad: undefined, + placement: 'none', + shape: feedHeroShape(1), + }); + // The cards and the headlines are separate lists, so the lead card need + // not be the first headline. + mockHero({ posts: [posts[1]], highlights }); + + renderComponent(); + + await waitFor(() => + expect( + screen.getByRole('heading', { name: 'Second hero post' }), + ).toBeInTheDocument(), + ); + expect(screen.getByText('First headline')).toBeInTheDocument(); + expect(screen.queryByText('Second headline')).not.toBeInTheDocument(); + expect(screen.getByText('Third headline')).toBeInTheDocument(); + }); + + it('should render nothing when the query returns no posts', async () => { + mockHero({ posts: [], highlights: [] }); + + const { container } = renderComponent(); + + await waitFor(() => expect(nock.isDone()).toBe(true)); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/packages/shared/src/components/feeds/hero/FeedHero.tsx b/packages/shared/src/components/feeds/hero/FeedHero.tsx index e42306e3cd..48c7de3e88 100644 --- a/packages/shared/src/components/feeds/hero/FeedHero.tsx +++ b/packages/shared/src/components/feeds/hero/FeedHero.tsx @@ -2,14 +2,13 @@ import type { ReactElement } from 'react'; import React, { useCallback, useEffect, useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import type { Post } from '../../../graphql/posts'; -import type { Connection } from '../../../graphql/common'; import { gqlClient } from '../../../graphql/common'; +import type { FeedHeroData } from '../../../graphql/feed'; import { - FEED_BY_IDS_QUERY, + FEED_HERO_QUERY, supportedTypesForPrivateSources, } from '../../../graphql/feed'; import type { PostHighlight } from '../../../graphql/highlights'; -import { majorHeadlinesQueryOptions } from '../../../graphql/highlights'; import type { ViewabilityData } from '../../../features/monetization/viewability'; import { viewabilityLogExtra } from '../../../features/monetization/viewability'; import { useAuthContext } from '../../../contexts/AuthContext'; @@ -29,15 +28,14 @@ import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query'; import { FeedHeroSection } from './FeedHeroSection'; import { useFeedHeroAd } from './useFeedHeroAd'; -const HIGHLIGHT_COUNT = 6; -const FEATURED_POST_COUNT = 4; // Distinct from `Origin.Feed` so the experiment can tell the hero's clicks and // impressions apart from the grid's. Matches the ad events' own origin. const HERO_ORIGIN = 'feed hero'; /** - * The carousel and the Happening Now list are the same headlines: the top few - * get their full post fetched for a card, the rest stay as rows. + * The carousel and the Happening Now list are two lists, not one: `feedHero` + * grades the cards across editorial highlights and lifecycle states, while the + * rows beside them stay major headlines. A post can be in both, or in one. */ export const FeedHero = ({ feedName, @@ -66,51 +64,36 @@ export const FeedHero = ({ const { ad, placement, shape } = useFeedHeroAd(); - const { data: headlines } = useQuery({ - ...majorHeadlinesQueryOptions({ first: HIGHLIGHT_COUNT }), - enabled: tokenRefreshed, - }); - const highlights = useMemo( - () => headlines?.majorHeadlines?.edges?.map(({ node }) => node) ?? [], - [headlines], - ); - - const postIds = useMemo( - () => highlights.slice(0, FEATURED_POST_COUNT).map(({ post }) => post.id), - [highlights], - ); - - const { data: featured } = useQuery({ - queryKey: generateQueryKey(RequestKey.FeedByIds, user, 'hero', ...postIds), + const { data: hero } = useQuery({ + queryKey: generateQueryKey(RequestKey.FeedHero, user), queryFn: () => - gqlClient.request<{ page: Connection }>(FEED_BY_IDS_QUERY, { - first: postIds.length, - postIds, + gqlClient.request(FEED_HERO_QUERY, { loggedIn: !!user, supportedTypes: supportedTypesForPrivateSources, }), - enabled: tokenRefreshed && postIds.length > 0, + enabled: tokenRefreshed, staleTime: StaleTime.Default, }); - // `feedByIds` answers in its own order, so re-key by id to keep the carousel - // in the same order as the headlines beside it. - const posts = useMemo(() => { - const byId = new Map( - featured?.page?.edges?.map(({ node }) => [node.id, node]) ?? [], - ); - - return postIds.map((id) => byId.get(id)).filter(Boolean) as Post[]; - }, [featured, postIds]); + const highlights = useMemo(() => hero?.feedHero?.highlights ?? [], [hero]); + const posts: Post[] = useMemo(() => hero?.feedHero?.posts ?? [], [hero]); const isRendered = posts.length > 0; const adPlacement = isRendered ? placement : 'none'; const isAdShown = adPlacement !== 'none'; // Stacked, the lead story is already a card above the list, so drop it from - // the list rather than showing it twice a few pixels apart. - const railHighlights = - shape.layout === 'stacked' ? highlights.slice(1) : highlights; + // the list rather than showing it twice a few pixels apart. Matched on the + // post, not the position: the cards and the headlines are separate lists. + const railHighlights = useMemo(() => { + const leadPostId = posts[0]?.id; + + if (shape.layout !== 'stacked' || !leadPostId) { + return highlights; + } + + return highlights.filter(({ post }) => post.id !== leadPostId); + }, [highlights, posts, shape.layout]); const onAdAction = useCallback( (action: AdActions, extra?: Record) => { diff --git a/packages/shared/src/graphql/feed.ts b/packages/shared/src/graphql/feed.ts index 3aa7ff3c32..01723af9b2 100644 --- a/packages/shared/src/graphql/feed.ts +++ b/packages/shared/src/graphql/feed.ts @@ -412,6 +412,31 @@ export const FEED_V2_QUERY = gql` ${POST_HIGHLIGHT_FRAGMENT} `; +export interface FeedHeroData { + feedHero: { + posts: Post[]; + highlights: PostHighlight[]; + }; +} + +export const FEED_HERO_QUERY = gql` + query FeedHero($loggedIn: Boolean! = false, ${SUPPORTED_TYPES}) { + feedHero(supportedTypes: $supportedTypes) { + posts { + ...FeedPost + contentHtml + ...UserPost @include(if: $loggedIn) + } + highlights { + ...PostHighlightCard + } + } + } + ${FEED_POST_FRAGMENT} + ${USER_POST_FRAGMENT} + ${POST_HIGHLIGHT_FRAGMENT} +`; + export const MOST_UPVOTED_FEED_QUERY = gql` query MostUpvotedFeed( $loggedIn: Boolean! = false diff --git a/packages/shared/src/lib/query.ts b/packages/shared/src/lib/query.ts index e9f0006a6d..4eeb4e4f15 100644 --- a/packages/shared/src/lib/query.ts +++ b/packages/shared/src/lib/query.ts @@ -189,6 +189,7 @@ export enum RequestKey { FeedSettings = 'feedSettings', Ads = 'ads', FeedByIds = 'feedByIds', + FeedHero = 'feedHero', SlackChannels = 'slack_channels', IntegrationRecentChannels = 'integration_recent_channels', UserIntegrations = 'user_integrations',