Skip to content

Commit f5145fa

Browse files
rebelchrisclaude
andauthored
feat(feed): widget rail foundation (data, layout, registry) (#6963)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 201b7e3 commit f5145fa

23 files changed

Lines changed: 2237 additions & 69 deletions

‎packages/shared/src/components/Feed.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ export interface FeedProps<T>
111111
skipFirstAd?: boolean;
112112
/** The surface leads with a featured card, so keep wide ones out of row one. */
113113
deferWideCards?: boolean;
114+
/** Posts a surface above the feed already shows, kept out of the grid. */
115+
excludedPostIds?: string[];
114116
staticAd?: { ad: Ad; index: number };
115117
disableAdRefresh?: boolean;
116118
allowFetchMore?: boolean;
@@ -213,6 +215,7 @@ export default function Feed<T>({
213215
disableTopHero,
214216
skipFirstAd,
215217
deferWideCards,
218+
excludedPostIds,
216219
staticAd,
217220
disableAdRefresh = false,
218221
allowFetchMore,
@@ -380,6 +383,7 @@ export default function Feed<T>({
380383
disableHighlightCards,
381384
skipFirstAd,
382385
deferWideCards,
386+
excludedPostIds,
383387
staticAd,
384388
adPostLength: isSquadFeed ? 2 : undefined,
385389
feedName,

‎packages/shared/src/components/feeds/hero/FeedHero.tsx‎

Lines changed: 4 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,9 @@
11
import type { ReactElement } from 'react';
22
import React, { useCallback, useEffect, useMemo } from 'react';
3-
import { useQuery } from '@tanstack/react-query';
4-
import type { Post } from '../../../graphql/posts';
5-
import { gqlClient } from '../../../graphql/common';
6-
import type { FeedHeroData } from '../../../graphql/feed';
7-
import { FEED_HERO_QUERY, baseFeedSupportedTypes } from '../../../graphql/feed';
83
import type { PostHighlight } from '../../../graphql/highlights';
94
import type { ViewabilityData } from '../../../features/monetization/viewability';
105
import { viewabilityLogExtra } from '../../../features/monetization/viewability';
11-
import { useAuthContext } from '../../../contexts/AuthContext';
126
import { useLogContext } from '../../../contexts/LogContext';
13-
import { useCopyLink } from '../../../hooks/useCopy';
147
import { ImpressionStatus } from '../../../hooks/feed/useLogImpression';
158
import {
169
adLogEvent,
@@ -19,11 +12,10 @@ import {
1912
} from '../../../lib/feed';
2013
import { AdActions } from '../../../lib/ads';
2114
import { LogEvent, Origin } from '../../../lib/log';
22-
import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query';
2315
import { FeedHeroSection } from './FeedHeroSection';
2416
import { useFeedHeroAd } from './useFeedHeroAd';
25-
import { useFeedHeroPostActions } from './useFeedHeroPostActions';
26-
import { useCachedTokenRecovery } from '../../../hooks/useCachedTokenRecovery';
17+
import { useFeedHeroData } from './useFeedHeroData';
18+
import { useFeedHeroCardProps } from './useFeedHeroCardProps';
2719

2820
/**
2921
* The carousel and the Happening Now list are two lists, not one: `feedHero`
@@ -48,42 +40,13 @@ export const FeedHero = ({
4840
*/
4941
onRenderedChange?: (isRendered: boolean) => void;
5042
}): ReactElement | null => {
51-
const { user, isTokenValid } = useAuthContext();
5243
const { logEvent } = useLogContext();
5344
const postLogEvent = usePostLogEvent();
54-
const [, copyLink] = useCopyLink();
5545

5646
const { ad, placement, shape } = useFeedHeroAd();
5747

58-
const queryKey = useMemo(
59-
() => generateQueryKey(RequestKey.FeedHero, user),
60-
[user],
61-
);
62-
// Vote and bookmark patch the hero's own query: the bare hooks only write the
63-
// single-post key, which these cards never read.
64-
const { toggleUpvote, toggleDownvote, toggleBookmark } =
65-
useFeedHeroPostActions({
66-
queryKey,
67-
feedName,
68-
origin: Origin.FeedHero,
69-
});
70-
71-
const { data: hero } = useQuery({
72-
queryKey,
73-
queryFn: () =>
74-
gqlClient.request<FeedHeroData>(FEED_HERO_QUERY, {
75-
loggedIn: !!user,
76-
supportedTypes: baseFeedSupportedTypes,
77-
}),
78-
enabled: isTokenValid,
79-
// The same window `majorHeadlinesQueryOptions` gives the in-feed card, so
80-
// the two surfaces the experiment compares are equally fresh.
81-
staleTime: StaleTime.OneMinute,
82-
});
83-
useCachedTokenRecovery({ queryKey, enabled: isTokenValid });
84-
85-
const highlights = useMemo(() => hero?.feedHero?.highlights ?? [], [hero]);
86-
const posts: Post[] = useMemo(() => hero?.feedHero?.posts ?? [], [hero]);
48+
const { queryKey, posts, highlights } = useFeedHeroData();
49+
const cardProps = useFeedHeroCardProps({ queryKey, feedName });
8750

8851
const isRendered = posts.length > 0;
8952
const adPlacement = isRendered ? placement : 'none';
@@ -153,33 +116,6 @@ export const FeedHero = ({
153116
ad.impressionStatus = ImpressionStatus.LOGGED;
154117
}, [ad, isAdShown, onAdAction]);
155118

156-
const cardProps = useMemo(
157-
() => ({
158-
onPostClick: (post: Post) =>
159-
logEvent(
160-
postLogEvent(LogEvent.Click, post, {
161-
extra: { origin: Origin.FeedHero },
162-
}),
163-
),
164-
onUpvoteClick: (post: Post, origin = Origin.FeedHero) =>
165-
toggleUpvote({ payload: post, origin }),
166-
onDownvoteClick: (post: Post, origin = Origin.FeedHero) =>
167-
toggleDownvote({ payload: post, origin }),
168-
onBookmarkClick: (post: Post, origin = Origin.FeedHero) =>
169-
toggleBookmark({ post, origin }),
170-
onCopyLinkClick: (_: React.MouseEvent, post: Post) =>
171-
copyLink({ link: post.commentsPermalink }),
172-
}),
173-
[
174-
copyLink,
175-
logEvent,
176-
postLogEvent,
177-
toggleBookmark,
178-
toggleDownvote,
179-
toggleUpvote,
180-
],
181-
);
182-
183119
if (!isRendered) {
184120
return null;
185121
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type { MouseEvent } from 'react';
2+
import { useMemo } from 'react';
3+
import type { QueryKey } from '@tanstack/react-query';
4+
import type { Post } from '../../../graphql/posts';
5+
import type { FeaturedWideCardProps } from '../../cards/common/featuredWide';
6+
import { useLogContext } from '../../../contexts/LogContext';
7+
import { useCopyLink } from '../../../hooks/useCopy';
8+
import { usePostLogEvent } from '../../../lib/feed';
9+
import { LogEvent, Origin } from '../../../lib/log';
10+
import { useFeedHeroPostActions } from './useFeedHeroPostActions';
11+
12+
/**
13+
* The handlers the hero's cards take, logged under `Origin.FeedHero`. Vote
14+
* and bookmark patch the hero's own query: the bare hooks only write the
15+
* single-post key, which these cards never read.
16+
*/
17+
export const useFeedHeroCardProps = ({
18+
queryKey,
19+
feedName,
20+
}: {
21+
queryKey: QueryKey;
22+
feedName: string;
23+
}): Omit<FeaturedWideCardProps, 'post'> => {
24+
const { logEvent } = useLogContext();
25+
const postLogEvent = usePostLogEvent();
26+
const [, copyLink] = useCopyLink();
27+
const { toggleUpvote, toggleDownvote, toggleBookmark } =
28+
useFeedHeroPostActions({
29+
queryKey,
30+
feedName,
31+
origin: Origin.FeedHero,
32+
});
33+
34+
return useMemo(
35+
() => ({
36+
onPostClick: (post: Post) =>
37+
logEvent(
38+
postLogEvent(LogEvent.Click, post, {
39+
extra: { origin: Origin.FeedHero },
40+
}),
41+
),
42+
onUpvoteClick: (post: Post, origin = Origin.FeedHero) =>
43+
toggleUpvote({ payload: post, origin }),
44+
onDownvoteClick: (post: Post, origin = Origin.FeedHero) =>
45+
toggleDownvote({ payload: post, origin }),
46+
onBookmarkClick: (post: Post, origin = Origin.FeedHero) =>
47+
toggleBookmark({ post, origin }),
48+
onCopyLinkClick: (_: MouseEvent, post: Post) =>
49+
copyLink({ link: post.commentsPermalink }),
50+
}),
51+
[
52+
copyLink,
53+
logEvent,
54+
postLogEvent,
55+
toggleBookmark,
56+
toggleDownvote,
57+
toggleUpvote,
58+
],
59+
);
60+
};
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { ReactElement, ReactNode } from 'react';
2+
import React from 'react';
3+
import nock from 'nock';
4+
import { renderHook, waitFor } from '@testing-library/react';
5+
import { QueryClient } from '@tanstack/react-query';
6+
import basePost from '../../../../__tests__/fixture/post';
7+
import { TestBootProvider } from '../../../../__tests__/helpers/boot';
8+
import { mockGraphQL } from '../../../../__tests__/helpers/graphql';
9+
import { FEED_HERO_QUERY, baseFeedSupportedTypes } from '../../../graphql/feed';
10+
import { storageWrapper } from '../../../lib/storageWrapper';
11+
import { generateStorageKey, StorageTopic } from '../../../lib/storage';
12+
import { clearHeldFeedHeroLeads, useFeedHeroData } from './useFeedHeroData';
13+
14+
const post = (id: string) => ({ ...basePost, id, title: id });
15+
16+
const mockHero = (posts: ReturnType<typeof post>[]) =>
17+
mockGraphQL({
18+
request: {
19+
query: FEED_HERO_QUERY,
20+
variables: { loggedIn: false, supportedTypes: baseFeedSupportedTypes },
21+
},
22+
result: { data: { feedHero: { posts, highlights: [] } } },
23+
});
24+
25+
const cacheKey = generateStorageKey(StorageTopic.Post, 'feedHero', 'anonymous');
26+
27+
const renderData = (client = new QueryClient()) =>
28+
renderHook(() => useFeedHeroData(), {
29+
wrapper: ({ children }: { children: ReactNode }): ReactElement => (
30+
<TestBootProvider client={client}>{children}</TestBootProvider>
31+
),
32+
});
33+
34+
beforeEach(() => {
35+
nock.cleanAll();
36+
clearHeldFeedHeroLeads();
37+
});
38+
39+
it('should start from the last payload and keep its lead for the session', async () => {
40+
storageWrapper.setItem(
41+
cacheKey,
42+
JSON.stringify({
43+
data: { feedHero: { posts: [post('cached')], highlights: [] } },
44+
savedAt: 0,
45+
}),
46+
);
47+
mockHero([post('fresh')]);
48+
49+
const { result } = renderData();
50+
51+
expect(result.current.leadPost?.id).toEqual('cached');
52+
await waitFor(() => expect(result.current.posts[0]?.id).toEqual('fresh'));
53+
expect(result.current.leadPost?.id).toEqual('cached');
54+
// The next load starts from what this one fetched.
55+
expect(JSON.parse(storageWrapper.getItem(cacheKey) ?? '{}')).toMatchObject({
56+
data: { feedHero: { posts: [{ id: 'fresh' }] } },
57+
});
58+
});
59+
60+
it('should hold the first lead it fetched when nothing was cached', async () => {
61+
mockHero([post('first')]);
62+
63+
const { result } = renderData();
64+
65+
await waitFor(() => expect(result.current.leadPost?.id).toEqual('first'));
66+
});
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { useEffect, useMemo } from 'react';
2+
import { useQuery } from '@tanstack/react-query';
3+
import type { QueryKey } from '@tanstack/react-query';
4+
import type { Post } from '../../../graphql/posts';
5+
import { gqlClient } from '../../../graphql/common';
6+
import type { FeedHeroData } from '../../../graphql/feed';
7+
import { FEED_HERO_QUERY, baseFeedSupportedTypes } from '../../../graphql/feed';
8+
import type { PostHighlight } from '../../../graphql/highlights';
9+
import { useAuthContext } from '../../../contexts/AuthContext';
10+
import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query';
11+
import { useCachedTokenRecovery } from '../../../hooks/useCachedTokenRecovery';
12+
import { storageWrapper } from '../../../lib/storageWrapper';
13+
import { generateStorageKey, StorageTopic } from '../../../lib/storage';
14+
15+
const empty: never[] = [];
16+
17+
type CachedFeedHero = { data: FeedHeroData; savedAt: number };
18+
19+
const storageKey = (userId?: string): string =>
20+
generateStorageKey(StorageTopic.Post, 'feedHero', userId ?? 'anonymous');
21+
22+
const readCachedFeedHero = (userId?: string): CachedFeedHero | undefined => {
23+
const raw = storageWrapper.getItem(storageKey(userId));
24+
if (!raw) {
25+
return undefined;
26+
}
27+
try {
28+
const parsed = JSON.parse(raw) as CachedFeedHero;
29+
return Array.isArray(parsed?.data?.feedHero?.posts) &&
30+
typeof parsed.savedAt === 'number'
31+
? parsed
32+
: undefined;
33+
} catch (err) {
34+
return undefined;
35+
}
36+
};
37+
38+
/**
39+
* The lead post each reader saw first in this page session, by reader. The
40+
* top story shows it and the feed keeps it out of its grid, so the two agree
41+
* however often the hero query refreshes; a new lead waits for the next load.
42+
*/
43+
const heldLeads = new Map<string, Post>();
44+
45+
/** A fresh session's leads, for tests that render several sessions. */
46+
export const clearHeldFeedHeroLeads = (): void => heldLeads.clear();
47+
48+
/**
49+
* The hero's one query, shared by the hero section and the top story and
50+
* Happening Now widgets, so the rail fetches the headlines once however the
51+
* reader arranged them. Vote and bookmark patch this key through
52+
* `useFeedHeroPostActions`.
53+
*
54+
* It starts from the last payload this reader got, cached per reader, so the
55+
* rail paints with its content on the first frame and revalidates in the
56+
* background for the next load.
57+
*/
58+
export const useFeedHeroData = ({
59+
enabled = true,
60+
}: { enabled?: boolean } = {}): {
61+
queryKey: QueryKey;
62+
posts: Post[];
63+
highlights: PostHighlight[];
64+
/** The lead post held for this session (the live copy when it has one). */
65+
leadPost?: Post;
66+
isPending: boolean;
67+
} => {
68+
const { user, isTokenValid } = useAuthContext();
69+
const queryKey = useMemo(
70+
() => generateQueryKey(RequestKey.FeedHero, user),
71+
[user],
72+
);
73+
const isEnabled = isTokenValid && enabled;
74+
const cached = useMemo(() => readCachedFeedHero(user?.id), [user?.id]);
75+
76+
const { data, isPending, isFetchedAfterMount, dataUpdatedAt } = useQuery({
77+
queryKey,
78+
queryFn: () =>
79+
gqlClient.request<FeedHeroData>(FEED_HERO_QUERY, {
80+
loggedIn: !!user,
81+
supportedTypes: baseFeedSupportedTypes,
82+
}),
83+
enabled: isEnabled,
84+
// The same window `majorHeadlinesQueryOptions` gives the in-feed card, so
85+
// the two surfaces the experiment compares are equally fresh.
86+
staleTime: StaleTime.OneMinute,
87+
// Older than the stale time, so the cached copy paints and is refetched.
88+
initialData: cached?.data,
89+
initialDataUpdatedAt: cached?.savedAt,
90+
});
91+
useCachedTokenRecovery({ queryKey, enabled: isEnabled });
92+
93+
// What the next load starts from.
94+
useEffect(() => {
95+
if (!isFetchedAfterMount || !data?.feedHero) {
96+
return;
97+
}
98+
storageWrapper.setItem(
99+
storageKey(user?.id),
100+
JSON.stringify({ data, savedAt: dataUpdatedAt }),
101+
);
102+
}, [data, dataUpdatedAt, isFetchedAfterMount, user?.id]);
103+
104+
const posts = data?.feedHero?.posts ?? empty;
105+
const readerKey = user?.id ?? 'anonymous';
106+
if (!heldLeads.has(readerKey) && posts[0]) {
107+
heldLeads.set(readerKey, posts[0]);
108+
}
109+
const held = heldLeads.get(readerKey);
110+
// The held post by id, so votes and bookmarks on the live copy still show.
111+
const leadPost = held && (posts.find(({ id }) => id === held.id) ?? held);
112+
113+
return {
114+
queryKey,
115+
posts,
116+
highlights: data?.feedHero?.highlights ?? empty,
117+
leadPost,
118+
isPending,
119+
};
120+
};

0 commit comments

Comments
 (0)