Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions hooks/use-feed-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useAuth } from '@/contexts/auth-context';
import { useAsyncState } from '@/components/ui/loading-state';
import { Post } from '@/lib/types';
import { cacheManager } from '@/lib/cache-manager';
import { useProgressiveEnrichment } from '@/hooks/use-progressive-enrichment';
import { useProgressiveEnrichment, type PreloadedEnrichment } from '@/hooks/use-progressive-enrichment';
import { enrichPostsWithRepostsAndQuotes } from '@/lib/feed/enrich-posts';
import { loadFollowingFeed, type FollowingFeedWindow } from '@/lib/feed/load-following-feed';
import { loadForYouFeed } from '@/lib/feed/load-for-you-feed';
Expand Down Expand Up @@ -287,6 +287,8 @@ export function useFeedData({ activeTab, feedLanguage }: UseFeedDataOptions): Us
}

let posts: Post[] = [];
// Enrichment that arrived with the posts (composite For You pages).
let forYouPreloaded: PreloadedEnrichment | undefined;

if (activeTab === 'following' && user?.identityId) {
let followingPosts: Post[] = [];
Expand Down Expand Up @@ -321,13 +323,15 @@ export function useFeedData({ activeTab, feedLanguage }: UseFeedDataOptions): Us
const forYouResult = await loadForYouFeed({
startAfter: pagination?.startAfter,
feedLanguage,
currentUserId: user?.identityId,
setData,
setHasMore,
setLastPostId,
enrichProgressively,
});

posts = forYouResult.posts;
forYouPreloaded = forYouResult.preloaded;

if (posts.length === 0) {
logger.debug('Feed: No posts found on platform');
Expand Down Expand Up @@ -367,7 +371,7 @@ export function useFeedData({ activeTab, feedLanguage }: UseFeedDataOptions): Us
}

if (activeTab !== 'following') {
enrichProgressively(sortedPosts);
enrichProgressively(sortedPosts, forYouPreloaded);
}

if (!isPaginating && sortedPosts.length > 0) {
Expand Down
61 changes: 50 additions & 11 deletions hooks/use-progressive-enrichment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,21 @@ export interface ReplyToData {
authorUsername: string | null // null = no DPNS
}

/**
* Enrichment slices that arrived with the posts themselves (a composite
* feed page proves the counts, profiles, names and the viewer's marks
* under the same root as the posts). Keys present here are merged into
* the state at once and excluded from the follow-up queries; keys absent
* are fetched as before, so partial preloads degrade gracefully.
*/
export interface PreloadedEnrichment {
usernames?: Map<string, string | null>
profiles?: Map<string, ProfileData>
avatars?: Map<string, string>
stats?: Map<string, PostStats>
interactions?: Map<string, UserInteractions>
}

export interface EnrichmentState {
// Author data keyed by authorId
usernames: Map<string, string | null> // authorId → DPNS username (null = no DPNS)
Expand Down Expand Up @@ -70,7 +85,7 @@ interface UseProgressiveEnrichmentOptions {
}

interface UseProgressiveEnrichmentResult {
enrichProgressively: (posts: Post[]) => void
enrichProgressively: (posts: Post[], preloaded?: PreloadedEnrichment) => void
enrichmentState: EnrichmentState
reset: () => void
getPostEnrichment: (post: Post) => {
Expand Down Expand Up @@ -119,7 +134,7 @@ export function useProgressiveEnrichment(
* Start progressive enrichment for the given posts.
* Non-blocking - returns immediately and updates state as data loads.
*/
const enrichProgressively = useCallback((posts: Post[]) => {
const enrichProgressively = useCallback((posts: Post[], preloaded?: PreloadedEnrichment) => {
if (posts.length === 0) return

// Increment request ID to invalidate any in-flight requests
Expand Down Expand Up @@ -150,21 +165,39 @@ export function useProgressiveEnrichment(
new Set([...authorIds, ...posts.map(p => p.quotedPost?.author.id).filter((id): id is string => !!id)])
)

// Set loading phase
setEnrichmentState(prev => ({ ...prev, phase: 'loading' }))

// Helper to merge Maps (TypeScript-compatible without downlevelIteration)
const mergeMaps = <K, V>(prev: Map<K, V>, next: Map<K, V>): Map<K, V> => {
const merged = new Map(prev)
next.forEach((value, key) => merged.set(key, value))
return merged
}

// Set loading phase, merging whatever arrived with the posts
setEnrichmentState(prev => ({
...prev,
phase: 'loading',
usernames: preloaded?.usernames ? mergeMaps(prev.usernames, preloaded.usernames) : prev.usernames,
profiles: preloaded?.profiles ? mergeMaps(prev.profiles, preloaded.profiles) : prev.profiles,
avatars: preloaded?.avatars ? mergeMaps(prev.avatars, preloaded.avatars) : prev.avatars,
stats: preloaded?.stats ? mergeMaps(prev.stats, preloaded.stats) : prev.stats,
interactions: preloaded?.interactions ? mergeMaps(prev.interactions, preloaded.interactions) : prev.interactions,
}))

// Only what the preload did not cover goes to the network
const notIn = <V,>(map: Map<string, V> | undefined) => (id: string) => !map?.has(id)
const usernameAuthorIds = authorIds.filter(notIn(preloaded?.usernames))
const profileAuthorIds = authorIds.filter(notIn(preloaded?.profiles))
const avatarAuthorIds = authorIds.filter(notIn(preloaded?.avatars))
const statsTargets = targets.filter(target => notIn(preloaded?.stats)(target.id))
const interactionTargets = targets.filter(target => notIn(preloaded?.interactions)(target.id))

// Store promises so we can reuse them for completion tracking
// This prevents duplicate queries that were happening before

// Priority 1: DPNS usernames (most visible - author identity)
const usernamePromise = dpnsService.resolveUsernamesBatch(authorIds)
const usernamePromise = usernameAuthorIds.length > 0
? dpnsService.resolveUsernamesBatch(usernameAuthorIds)
: Promise.resolve(new Map<string, string | null>())
usernamePromise.then(usernames => {
if (!isValid()) return
setEnrichmentState(prev => ({
Expand All @@ -174,7 +207,9 @@ export function useProgressiveEnrichment(
}).catch(err => logger.error('Progressive enrichment: usernames failed', err))

// Priority 1: Profiles (display names)
const profilePromise = unifiedProfileService.getProfilesByIdentityIds(authorIds)
const profilePromise = profileAuthorIds.length > 0
? unifiedProfileService.getProfilesByIdentityIds(profileAuthorIds)
: Promise.resolve([] as Awaited<ReturnType<typeof unifiedProfileService.getProfilesByIdentityIds>>)
// Seed queried authors that have no entry yet, so consumers can
// distinguish "loaded, no profile" (empty entry) from "still loading"
// (no entry) without clobbering profiles from earlier batches
Expand Down Expand Up @@ -215,7 +250,9 @@ export function useProgressiveEnrichment(
})

// Priority 2: Avatars
const avatarPromise = unifiedProfileService.getAvatarUrlsBatch(authorIds)
const avatarPromise = avatarAuthorIds.length > 0
? unifiedProfileService.getAvatarUrlsBatch(avatarAuthorIds)
: Promise.resolve(new Map<string, string>())
avatarPromise.then(avatars => {
if (!isValid()) return
setEnrichmentState(prev => ({
Expand All @@ -225,7 +262,9 @@ export function useProgressiveEnrichment(
}).catch(err => logger.error('Progressive enrichment: avatars failed', err))

// Priority 3: Stats
const statsPromise = postService.getBatchPostStats(targets)
const statsPromise = statsTargets.length > 0
? postService.getBatchPostStats(statsTargets)
: Promise.resolve(new Map<string, PostStats>())
statsPromise.then(stats => {
if (!isValid()) return
setEnrichmentState(prev => ({
Expand All @@ -235,8 +274,8 @@ export function useProgressiveEnrichment(
}).catch(err => logger.error('Progressive enrichment: stats failed', err))

// Priority 4: User interactions (only if logged in)
const interactionsPromise = currentUserId
? postService.getBatchUserInteractions(targets)
const interactionsPromise = currentUserId && interactionTargets.length > 0
? postService.getBatchUserInteractions(interactionTargets)
: Promise.resolve(new Map<string, UserInteractions>())

if (currentUserId) {
Expand Down
145 changes: 145 additions & 0 deletions lib/feed/composite-feed-page.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// Exercise the loader and decoders with an in-memory SDK boundary. No SDK
// initialization, browser storage or network calls are made by these tests.
const mocks = vi.hoisted(() => ({
composite: vi.fn(),
getEvoSdk: vi.fn(),
seedUsernames: vi.fn(),
seedProfiles: vi.fn(() => new Map()),
resolveAuthors: vi.fn(),
}));
vi.mock('@/lib/services/evo-sdk-service', () => ({ getEvoSdk: mocks.getEvoSdk }));
vi.mock('@/lib/services/dpns-service', () => ({ dpnsService: { seedUsernames: mocks.seedUsernames } }));
vi.mock('@/lib/services/unified-profile-service', () => ({
unifiedProfileService: {
seedProfileDocuments: mocks.seedProfiles,
getDefaultAvatarUrl: (id: string) => `avatar:${id}`,
},
}));
vi.mock('@/lib/services/post-enrichment-helpers', () => ({ resolvePostAuthorsBatch: mocks.resolveAuthors }));

const ownerIds = ['111111111', '222222222', '333333333', '444444444'];
const docs = ownerIds.map((ownerId, i) => ({
$id: `post0000${i}`, $ownerId: ownerId, $createdAt: 1000, content: 'test', language: 'en',
}));
const names = ownerIds.map((id, i) => ({ records: { identity: id }, label: `name${i}` }));
function result(dpns = names, page = docs) {
return {
pageDocuments: page,
subResults: [
...Array.from({ length: 4 }, () => ({ kind: 'counts', counts: new Map() })),
{ kind: 'documents', documents: [] },
{ kind: 'documents', documents: [] },
{ kind: 'documents', documents: dpns },
{ kind: 'documents', documents: [] },
],
};
}

beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
vi.stubEnv('NEXT_PUBLIC_CONTRACT_TOPOLOGY', 'v6');
mocks.getEvoSdk.mockResolvedValue({ documents: { composite: mocks.composite } });
mocks.composite.mockResolvedValue(result());
});
afterEach(() => vi.unstubAllEnvs());

describe('composite feed page', () => {
it('should preload all four named authors using a total budget of 100', async () => {
const { loadCompositeFeedPage } = await import('./composite-feed-page');
const page = await loadCompositeFeedPage({ language: 'en', limit: 20 });
expect(page?.preloaded.usernames?.size).toBe(4);
expect(page?.preloaded.usernames?.get(ownerIds[3])).toBe('name3.dash');
const query = mocks.composite.mock.calls[0][0];
expect(query.subQueries).toHaveLength(8);
expect(query.subQueries[6].limit).toBe(100);
});

it('should not preload partial primary names or negative cache entries at the cap', async () => {
mocks.composite.mockResolvedValue(result(Array.from({ length: 100 }, (_, i) => ({
records: { identity: ownerIds[0] }, label: `alias${i}`,
}))));
const { loadCompositeFeedPage } = await import('./composite-feed-page');
const page = await loadCompositeFeedPage({ language: 'en', limit: 20 });
expect(page?.preloaded.usernames?.size).toBe(0);
expect(mocks.seedUsernames).toHaveBeenCalledWith(new Map());
});

it('should seed absence only when the DPNS result leaves capacity for every author', async () => {
mocks.composite.mockResolvedValue(result(names.slice(0, 3)));
const { loadCompositeFeedPage } = await import('./composite-feed-page');
const page = await loadCompositeFeedPage({ language: 'en', limit: 20 });
expect(page?.preloaded.usernames?.get(ownerIds[3])).toBeNull();
});

it('should leave capacity for empty identity branches before trusting completeness', async () => {
mocks.composite.mockResolvedValue(result(Array.from({ length: 97 }, (_, i) => ({
records: { identity: ownerIds[0] }, label: `alias${i}`,
}))));
const { loadCompositeFeedPage } = await import('./composite-feed-page');
const page = await loadCompositeFeedPage({ language: 'en', limit: 20 });
expect(page?.preloaded.usernames?.size).toBe(0);
});

it('should query exact cursor-selected ids and restore their timeline order', async () => {
const { loadCompositeFeedPage } = await import('./composite-feed-page');
const ids = docs.map(doc => doc.$id).reverse();
const page = await loadCompositeFeedPage({ language: 'en', limit: 20, documentIds: ids });
expect(mocks.composite.mock.calls[0][0].where).toEqual([['$id', 'in', ids]]);
expect(mocks.composite.mock.calls[0][0].orderBy).toBeUndefined();
expect(page?.posts.map(post => post.id)).toEqual(ids);
});

it('should keep tombstones in the raw cursor page and remove them from cards', async () => {
mocks.composite.mockResolvedValue(result(names, docs.map((doc, i) => ({ ...doc, deleted: i === 3 }))));
const { loadCompositeFeedPage } = await import('./composite-feed-page');
const page = await loadCompositeFeedPage({ language: 'en', limit: 4 });
expect(page?.rawPosts).toHaveLength(4);
expect(page?.posts).toHaveLength(3);
expect(page?.hasMore).toBe(true);
});

it('should fit viewer interactions within ten subqueries', async () => {
const response = result();
response.subResults.push({ kind: 'documents', documents: [] }, { kind: 'documents', documents: [] });
mocks.composite.mockResolvedValue(response);
const { loadCompositeFeedPage } = await import('./composite-feed-page');
const page = await loadCompositeFeedPage({ language: 'en', limit: 20, currentUserId: ownerIds[0] });
expect(mocks.composite.mock.calls[0][0].subQueries).toHaveLength(10);
expect(page?.preloaded.interactions?.size).toBe(4);
});

it('should fall back without requesting composite documents on an old SDK', async () => {
mocks.getEvoSdk.mockResolvedValue({ documents: {} });
const { loadCompositeFeedPage } = await import('./composite-feed-page');
expect(await loadCompositeFeedPage({ language: 'en', limit: 20 })).toBeNull();
expect(mocks.composite).not.toHaveBeenCalled();
});

it('should fall back on unsupported nodes and back off for a minute', async () => {
mocks.composite.mockRejectedValue(new Error('unsupported sub_queries'));
const now = Date.now();
const time = vi.spyOn(Date, 'now').mockReturnValue(now);
try {
const { loadCompositeFeedPage } = await import('./composite-feed-page');
expect(await loadCompositeFeedPage({ language: 'en', limit: 20 })).toBeNull();
expect(await loadCompositeFeedPage({ language: 'en', limit: 20 })).toBeNull();
expect(mocks.composite).toHaveBeenCalledTimes(1);
time.mockReturnValue(now + 60_001);
mocks.composite.mockResolvedValue(result());
expect(await loadCompositeFeedPage({ language: 'en', limit: 20 })).not.toBeNull();
} finally {
time.mockRestore();
}
});

it('should fall back without seeding false absences on an incomplete response', async () => {
mocks.composite.mockResolvedValue({ pageDocuments: docs, subResults: [] });
const { loadCompositeFeedPage } = await import('./composite-feed-page');
expect(await loadCompositeFeedPage({ language: 'en', limit: 20 })).toBeNull();
expect(mocks.seedUsernames).not.toHaveBeenCalled();
expect(mocks.seedProfiles).not.toHaveBeenCalled();
});
});
Loading
Loading