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',