Skip to content

Commit a3dd3fd

Browse files
authored
fix(canvas): make the channel sidebar feel instant (#2681)
1 parent e3c320b commit a3dd3fd

3 files changed

Lines changed: 125 additions & 6 deletions

File tree

packages/api-client/src/posthog-client.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -851,11 +851,16 @@ export class PostHogAPIClient {
851851
// Desktop file system — the backend surface that backs canvas channels
852852
// (top-level folders) and dashboards. These routes aren't in the generated
853853
// OpenAPI client, so we use the raw fetcher.
854-
async getDesktopFileSystem(): Promise<Schemas.FileSystem[]> {
854+
// Channels are top-level folders on the desktop file system. Filtering to
855+
// `type=folder` server-side (and requesting a large page) keeps us from
856+
// paginating over every dashboard and filed task just to populate the
857+
// sidebar channel list — the bulk of the initial-load cost otherwise.
858+
async getDesktopFileSystemChannels(): Promise<Schemas.FileSystem[]> {
855859
const DESKTOP_FILE_SYSTEM_MAX_PAGES = 50;
860+
const DESKTOP_FILE_SYSTEM_PAGE_SIZE = 200;
856861
const teamId = await this.getTeamId();
857862
const all: Schemas.FileSystem[] = [];
858-
let urlPath: string = `/api/projects/${teamId}/desktop_file_system/`;
863+
let urlPath: string = `/api/projects/${teamId}/desktop_file_system/?type=folder&limit=${DESKTOP_FILE_SYSTEM_PAGE_SIZE}`;
859864
for (let i = 0; i < DESKTOP_FILE_SYSTEM_MAX_PAGES; i++) {
860865
const url = new URL(`${this.api.baseUrl}${urlPath}`);
861866
const response = await this.api.fetcher.fetch({
@@ -865,7 +870,7 @@ export class PostHogAPIClient {
865870
});
866871
if (!response.ok) {
867872
throw new Error(
868-
`Failed to fetch desktop file system: ${response.statusText}`,
873+
`Failed to fetch desktop file system channels: ${response.statusText}`,
869874
);
870875
}
871876
const page = (await response.json()) as Schemas.PaginatedFileSystemList;
@@ -875,7 +880,7 @@ export class PostHogAPIClient {
875880
urlPath = `${nextUrl.pathname}${nextUrl.search}`;
876881
}
877882
log.warn(
878-
`getDesktopFileSystem hit MAX_PAGES (${DESKTOP_FILE_SYSTEM_MAX_PAGES}); returning partial results`,
883+
`getDesktopFileSystemChannels hit MAX_PAGES (${DESKTOP_FILE_SYSTEM_MAX_PAGES}); returning partial results`,
879884
{ returned: all.length },
880885
);
881886
return all;
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import type { Schemas } from "@posthog/api-client";
2+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3+
import { act, renderHook, waitFor } from "@testing-library/react";
4+
import type { ReactNode } from "react";
5+
import { beforeEach, describe, expect, it, vi } from "vitest";
6+
7+
const mockClient = vi.hoisted(() => ({
8+
getDesktopFileSystemChannels: vi.fn(),
9+
createDesktopFileSystemChannel: vi.fn(),
10+
}));
11+
vi.mock("@posthog/ui/features/auth/authClient", () => ({
12+
useOptionalAuthenticatedClient: () => mockClient,
13+
}));
14+
15+
import { useChannelMutations, useChannels } from "./useChannels";
16+
17+
function folder(id: string, path: string): Schemas.FileSystem {
18+
return {
19+
id,
20+
path,
21+
type: "folder",
22+
depth: 1,
23+
created_at: "2026-01-01T00:00:00Z",
24+
last_viewed_at: null,
25+
};
26+
}
27+
28+
let queryClient: QueryClient;
29+
function wrapper({ children }: { children: ReactNode }) {
30+
return (
31+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
32+
);
33+
}
34+
35+
describe("useChannelMutations", () => {
36+
beforeEach(() => {
37+
vi.clearAllMocks();
38+
queryClient = new QueryClient({
39+
defaultOptions: { queries: { retry: false } },
40+
});
41+
});
42+
43+
it("shows the created channel immediately, before the refetch resolves", async () => {
44+
// Seed the list with one existing channel.
45+
mockClient.getDesktopFileSystemChannels.mockResolvedValue([
46+
folder("1", "alpha"),
47+
]);
48+
49+
const list = renderHook(() => useChannels(), { wrapper });
50+
await waitFor(() => expect(list.result.current.isLoading).toBe(false));
51+
expect(list.result.current.channels.map((c) => c.name)).toEqual(["alpha"]);
52+
53+
// Make the create return the new channel, but hang any subsequent refetch
54+
// so we can prove the list updates without waiting on it.
55+
const created = folder("2", "beta");
56+
mockClient.createDesktopFileSystemChannel.mockResolvedValue(created);
57+
mockClient.getDesktopFileSystemChannels.mockReturnValue(
58+
new Promise(() => {}),
59+
);
60+
61+
const mutations = renderHook(() => useChannelMutations(), { wrapper });
62+
await act(async () => {
63+
await mutations.result.current.createChannel("beta");
64+
});
65+
66+
// The new channel is present from the optimistic cache write, sorted
67+
// alphabetically alongside the existing one — without the hung refetch
68+
// having resolved.
69+
await waitFor(() =>
70+
expect(list.result.current.channels.map((c) => c.name)).toEqual([
71+
"alpha",
72+
"beta",
73+
]),
74+
);
75+
});
76+
77+
it("does not duplicate a channel the poll already landed", async () => {
78+
// The poll has already surfaced the channel we're about to create.
79+
const existing = folder("1", "alpha");
80+
mockClient.getDesktopFileSystemChannels.mockResolvedValue([existing]);
81+
82+
const list = renderHook(() => useChannels(), { wrapper });
83+
await waitFor(() => expect(list.result.current.isLoading).toBe(false));
84+
expect(list.result.current.channels.map((c) => c.id)).toEqual(["1"]);
85+
86+
// Create returns the same id; hang the refetch so only the optimistic
87+
// cache write is exercised.
88+
mockClient.createDesktopFileSystemChannel.mockResolvedValue(existing);
89+
mockClient.getDesktopFileSystemChannels.mockReturnValue(
90+
new Promise(() => {}),
91+
);
92+
93+
const mutations = renderHook(() => useChannelMutations(), { wrapper });
94+
await act(async () => {
95+
await mutations.result.current.createChannel("alpha");
96+
});
97+
98+
// The duplicate-id guard keeps the list at one entry.
99+
expect(list.result.current.channels.map((c) => c.id)).toEqual(["1"]);
100+
});
101+
});

packages/ui/src/features/canvas/hooks/useChannels.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export function useChannels(options?: { enabled?: boolean }): {
2626
} {
2727
const query = useAuthenticatedQuery<Schemas.FileSystem[]>(
2828
CHANNELS_QUERY_KEY,
29-
(client) => client.getDesktopFileSystem(),
29+
(client) => client.getDesktopFileSystemChannels(),
3030
{
3131
enabled: options?.enabled ?? true,
3232
refetchInterval: CHANNELS_POLL_INTERVAL_MS,
@@ -56,7 +56,20 @@ export function useChannelMutations() {
5656
if (!client) throw new Error("Not authenticated");
5757
return client.createDesktopFileSystemChannel(name);
5858
},
59-
onSuccess: invalidate,
59+
onSuccess: (newFs) => {
60+
// Insert the created channel into the cache immediately so the sidebar
61+
// updates the instant the POST resolves, rather than waiting on the
62+
// paginated refetch that `invalidate` triggers.
63+
queryClient.setQueryData<Schemas.FileSystem[]>(
64+
CHANNELS_QUERY_KEY,
65+
(old) => {
66+
if (!old) return [newFs];
67+
if (old.some((fs) => fs.id === newFs.id)) return old;
68+
return [...old, newFs];
69+
},
70+
);
71+
invalidate();
72+
},
6073
});
6174

6275
const deleteMutation = useMutation({

0 commit comments

Comments
 (0)