feedPartialFailure.test.tsx12.8 KBView on GitHub import { renderHook, waitFor } from '@testing-library/react';
import React from 'react';
/**
* One channel going down is not the inbox going down.
*
* The unified feed is one query PER channel, so Slack can fail while email and LinkedIn are
* perfectly healthy. Two things used to go wrong in that case, and both of them ended with the
* rep looking at nothing:
*
* 1. A failed channel never became `settled`, because `settled` was `data !== undefined` and
* a rejected query has no data. The merge refuses to render until every channel settles —
* correctly, since an unsettled channel could still return an item of any timestamp — so
* `ready` stayed false, `isLoading` stayed true, and `InboxList` (which checks loading
* before error) spun forever. Not "Slack is down": an indefinite spinner.
*
* 2. Even once it settled, `isError` was `some(channel => channel.isError)`, which blanks a
* working list and throws away every row the healthy channels did return.
*
* A failed channel is now settled-and-empty — it contributes nothing and bounds nothing, which
* is exactly how the merge already treats a channel that came back empty — and the feed errors
* only when it truly cannot answer.
*/
const mockReset = jest.fn();
jest.mock('@/modules/threads/threadList/hooks/use-threads-operations', () => ({
resetListThreadsToFirstPage: (...args: unknown[]) => mockReset(...args),
}));
/** Per-channel query results, keyed by the channel each query is asked for. */
type ChannelState = {
items?: unknown[];
threadIds?: string[][];
isError?: boolean;
/** Failed on a later fetch, so react-query still holds the previous page. */
retainsData?: boolean;
hasNextPage?: boolean;
headChanged?: boolean;
pending?: boolean;
};
const channelState: Record<string, ChannelState> = {};
/**
* An email row shaped the way `mail.listThreads` returns it.
*
* The timestamp comes from the id's own suffix rather than from which page carried the row, so
* `e1` is newer than `e2` no matter where it appears. That is what makes the overlap test below
* unambiguous: a row that shows up on two pages keeps ONE identity and one position.
*/
const emailThread = (id: string) => ({
id,
$raw: {
sender: { email: `${id}@example.com`, name: id },
labels: [],
latestReceivedOn: `2026-08-31T${20 - Number(id.slice(1))}:00:00.000Z`,
subject: `subject ${id}`,
snippet: '',
messageCount: 1,
},
});
/** A chat row shaped the way `inbox.listChannelItems` returns it. */
const chatItem = (channel: string, id: string, sortedAt: string) => ({
id: `${channel}:${id}`,
channel,
sortedAt,
counterpart: { name: id },
snippet: '',
unread: false,
starred: false,
hasDraft: false,
done: false,
labelIds: [],
participantCount: 1,
ref: { kind: channel, conversationId: id },
});
jest.mock('@tanstack/react-query', () => {
const actual = jest.requireActual('@tanstack/react-query');
return {
...actual,
useQueryClient: () => ({}),
useQuery: () => ({
data: { channels: ['email', 'linkedin', 'whatsapp', 'slack'], mailOnly: false },
isLoading: false,
isError: false,
refetch: jest.fn(),
}),
useInfiniteQuery: (options: { queryKey=[redacted], string, { channel?: string }] }) => {
const isEmail = options.queryKey[1] === 'listThreads';
const channel = isEmail ? 'email' : (options.queryKey[2].channel ?? 'unknown');
const state = channelState[channel] ?? {};
const base = {
isLoading: false,
isError: Boolean(state.isError),
hasNextPage: Boolean(state.hasNextPage),
isFetchingNextPage: false,
fetchNextPage: jest.fn(),
refetch: jest.fn(),
isPlaceholderData: false,
};
// A query that failed on its FIRST fetch has no data at all — the point of case 1 above.
// One that failed on a LATER fetch keeps the last good data, which `retainsData` models.
if ((state.isError && !state.retainsData) || state.pending) {
return { ...base, data: undefined };
}
const pages = isEmail
? (state.threadIds ?? []).map((ids, page) => ({
threads: ids.map(emailThread),
headChanged: page === 0 ? Boolean(state.headChanged) : false,
}))
: [{ items: state.items ?? [] }];
return { ...base, data: { pages } };
},
};
});
jest.mock('@/providers/query-provider', () => ({
useTRPC: () => ({
inbox: {
getFeedScope: {
queryOptions: (input: unknown, opts: Record<string, unknown>) => ({
queryKey: ['inbox', 'getFeedScope', input],
...opts,
}),
},
listChannelItems: {
infiniteQueryOptions: (input: unknown, opts: Record<string, unknown>) => ({
queryKey: ['inbox', 'listChannelItems', input],
...opts,
}),
},
},
mail: {
listThreads: {
infiniteQueryOptions: (input: unknown, opts: Record<string, unknown>) => ({
queryKey: ['mail', 'listThreads', input],
...opts,
}),
},
},
}),
}));
jest.mock('@/hooks/use-connections', () => ({
useActiveConnection: () => ({ data: { id: 'conn-1' } }),
}));
jest.mock('@/modules/store', () => ({
useCedarStore: (selector: (s: unknown) => unknown) =>
selector({
channelFeeds: {},
setChannelFeeds: jest.fn(),
batchPopulateThreadMetadata: jest.fn(),
}),
}));
import { useInboxItems } from '@/modules/inbox/hooks/use-inbox-items';
const wrapper = ({ children }: { children: React.ReactNode }) => <>{children}</>;
const render = (folder = 'inbox') =>
renderHook(({ f }: { f: string }) => useInboxItems({ channel: 'all', folder: f }), {
wrapper,
initialProps: { f: folder },
});
/** Every channel healthy, each holding one row, nothing left to page. */
const healthy = () => {
channelState.email = { threadIds: [['e1']] };
channelState.linkedin = { items: [chatItem('linkedin', 'l1', '2026-08-31T09:00:00.000Z')] };
channelState.whatsapp = { items: [chatItem('whatsapp', 'w1', '2026-08-31T08:00:00.000Z')] };
channelState.slack = { items: [chatItem('slack', 's1', '2026-08-31T07:00:00.000Z')] };
};
beforeEach(() => {
mockReset.mockClear();
for (const key of Object.keys(channelState)) delete channelState[key];
});
describe('unified feed — a failed channel does not take the inbox down with it', () => {
it('renders the healthy channels when one chat channel fails', () => {
healthy();
channelState.slack = { isError: true };
const { result } = render();
// The bug: this used to be `true` forever, because Slack never settled.
expect(result.current.isLoading).toBe(false);
// The other bug: this used to be `true`, blanking a list that had rows to show.
expect(result.current.isError).toBe(false);
expect(result.current.failedChannels).toEqual(['slack']);
const ids = result.current.items.map((item) => item.id);
expect(ids).toContain('email:e1');
expect(ids).toContain('linkedin:l1');
expect(ids).toContain('whatsapp:w1');
expect(ids).not.toContain('slack:s1');
});
it('renders the chat channels when EMAIL is the one that fails', () => {
healthy();
channelState.email = { isError: true };
const { result } = render();
expect(result.current.isLoading).toBe(false);
expect(result.current.isError).toBe(false);
expect(result.current.failedChannels).toEqual(['email']);
expect(result.current.items.map((item) => item.id)).toEqual([
'linkedin:l1',
'whatsapp:w1',
'slack:s1',
]);
});
it('names every failed channel, in participation order', () => {
healthy();
channelState.whatsapp = { isError: true };
channelState.slack = { isError: true };
const { result } = render();
expect(result.current.failedChannels).toEqual(['whatsapp', 'slack']);
expect(result.current.isError).toBe(false);
expect(result.current.items.map((item) => item.id)).toEqual(['email:e1', 'linkedin:l1']);
});
it('stops advertising more pages when every channel the next page would come from is down', () => {
// Slack keeps its loaded rows (react-query retains the last good data) and is still the
// newest tail, so it gates. Paging it is the only thing that would release the rows held
// beneath it — and paging it fails.
channelState.email = { threadIds: [['e1']] };
channelState.linkedin = { items: [chatItem('linkedin', 'l1', '2026-08-31T09:00:00.000Z')] };
channelState.whatsapp = { items: [chatItem('whatsapp', 'w1', '2026-08-31T08:00:00.000Z')] };
channelState.slack = {
items: [chatItem('slack', 's1', '2026-08-31T18:00:00.000Z')],
hasNextPage: true,
isError: true,
retainsData: true,
};
const { result } = render();
expect(result.current.failedChannels).toEqual(['slack']);
// Without this, "Load more" is offered and does nothing on every press.
expect(result.current.hasNextPage).toBe(false);
});
it('still offers more pages when a HEALTHY channel can supply them', () => {
healthy();
channelState.slack = { isError: true };
channelState.linkedin = {
items: [chatItem('linkedin', 'l1', '2026-08-31T09:00:00.000Z')],
hasNextPage: true,
};
const { result } = render();
expect(result.current.failedChannels).toEqual(['slack']);
expect(result.current.hasNextPage).toBe(true);
});
it('DOES error when every participating channel fails — there is nothing to show', () => {
channelState.email = { isError: true };
channelState.linkedin = { isError: true };
channelState.whatsapp = { isError: true };
channelState.slack = { isError: true };
const { result } = render();
expect(result.current.isError).toBe(true);
expect(result.current.isLoading).toBe(false);
expect(result.current.items).toEqual([]);
});
it('still waits for a channel that is merely SLOW — only a failure settles early', () => {
healthy();
channelState.slack = { pending: true };
const { result } = render();
// A pending channel could still return an item of any timestamp, so no prefix is provably
// stable and the feed is genuinely loading. This is the case the `settled` gate exists for,
// and the partial-failure fix must not swallow it.
expect(result.current.isLoading).toBe(true);
expect(result.current.isError).toBe(false);
expect(result.current.failedChannels).toEqual([]);
});
});
describe('unified feed — the email source acts on `headChanged`', () => {
it('trims the cached pages back to the first when the head shifts', async () => {
healthy();
channelState.email = { threadIds: [['e1']], headChanged: true };
render();
// Page-2+ cursors were minted against the old boundary; replaying them straddles the
// shifted head. The unified feed asks as its own surface, so the server hands it its own
// copy of the latch rather than letting the email list swallow it.
await waitFor(() => expect(mockReset).toHaveBeenCalledTimes(1));
});
it('does not fire when the head is steady', async () => {
healthy();
render();
await waitFor(() => expect(mockReset).not.toHaveBeenCalled());
});
it('resets again when the QUERY changes, not just when the flag flips', async () => {
healthy();
channelState.email = { threadIds: [['e1']], headChanged: true };
const { rerender } = render('inbox');
await waitFor(() => expect(mockReset).toHaveBeenCalledTimes(1));
// Switching folder swaps the cache entry underneath a hook instance that survives the
// switch. The new query's cached page 1 also says the head moved — and it is a DIFFERENT
// head, on a different list, that nothing has trimmed yet. A latch that remembered only
// the boolean would read "still true, no transition" and skip the reset this query needs.
rerender({ f: 'archive' });
await waitFor(() => expect(mockReset).toHaveBeenCalledTimes(2));
});
it('still does not re-fire while the same query keeps saying the head moved', async () => {
healthy();
channelState.email = { threadIds: [['e1']], headChanged: true };
const { rerender } = render('inbox');
await waitFor(() => expect(mockReset).toHaveBeenCalledTimes(1));
rerender({ f: 'inbox' });
rerender({ f: 'inbox' });
// The inline reconcile is idempotent; re-firing on every render would loop.
await waitFor(() => expect(mockReset).toHaveBeenCalledTimes(1));
});
it('drops a thread that two overlapping pages both carry', () => {
healthy();
// The transient overlap a shifted head produces: `e2` is the last row of page 1 and the
// first row of page 2. Both used to survive the flatten and the rep saw it twice.
channelState.email = { threadIds: [['e1', 'e2'], ['e2', 'e3']] };
const { result } = render();
const emailIds = result.current.items
.map((item) => item.id)
.filter((id) => id.startsWith('email:'));
expect(emailIds).toEqual(['email:e1', 'email:e2', 'email:e3']);
});
});