Skip to content
Merged
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
13 changes: 9 additions & 4 deletions packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -851,11 +851,16 @@ export class PostHogAPIClient {
// Desktop file system — the backend surface that backs canvas channels
// (top-level folders) and dashboards. These routes aren't in the generated
// OpenAPI client, so we use the raw fetcher.
async getDesktopFileSystem(): Promise<Schemas.FileSystem[]> {
// Channels are top-level folders on the desktop file system. Filtering to
// `type=folder` server-side (and requesting a large page) keeps us from
// paginating over every dashboard and filed task just to populate the
// sidebar channel list — the bulk of the initial-load cost otherwise.
async getDesktopFileSystemChannels(): Promise<Schemas.FileSystem[]> {
const DESKTOP_FILE_SYSTEM_MAX_PAGES = 50;
const DESKTOP_FILE_SYSTEM_PAGE_SIZE = 200;
const teamId = await this.getTeamId();
const all: Schemas.FileSystem[] = [];
let urlPath: string = `/api/projects/${teamId}/desktop_file_system/`;
let urlPath: string = `/api/projects/${teamId}/desktop_file_system/?type=folder&limit=${DESKTOP_FILE_SYSTEM_PAGE_SIZE}`;
for (let i = 0; i < DESKTOP_FILE_SYSTEM_MAX_PAGES; i++) {
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const response = await this.api.fetcher.fetch({
Expand All @@ -865,7 +870,7 @@ export class PostHogAPIClient {
});
if (!response.ok) {
throw new Error(
`Failed to fetch desktop file system: ${response.statusText}`,
`Failed to fetch desktop file system channels: ${response.statusText}`,
);
}
const page = (await response.json()) as Schemas.PaginatedFileSystemList;
Expand All @@ -875,7 +880,7 @@ export class PostHogAPIClient {
urlPath = `${nextUrl.pathname}${nextUrl.search}`;
}
log.warn(
`getDesktopFileSystem hit MAX_PAGES (${DESKTOP_FILE_SYSTEM_MAX_PAGES}); returning partial results`,
`getDesktopFileSystemChannels hit MAX_PAGES (${DESKTOP_FILE_SYSTEM_MAX_PAGES}); returning partial results`,
{ returned: all.length },
);
return all;
Expand Down
101 changes: 101 additions & 0 deletions packages/ui/src/features/canvas/hooks/useChannels.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { Schemas } from "@posthog/api-client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";

const mockClient = vi.hoisted(() => ({
getDesktopFileSystemChannels: vi.fn(),
createDesktopFileSystemChannel: vi.fn(),
}));
vi.mock("@posthog/ui/features/auth/authClient", () => ({
useOptionalAuthenticatedClient: () => mockClient,
}));

import { useChannelMutations, useChannels } from "./useChannels";

function folder(id: string, path: string): Schemas.FileSystem {
return {
id,
path,
type: "folder",
depth: 1,
created_at: "2026-01-01T00:00:00Z",
last_viewed_at: null,
};
}

Comment thread
greptile-apps[bot] marked this conversation as resolved.
let queryClient: QueryClient;
function wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

describe("useChannelMutations", () => {
beforeEach(() => {
vi.clearAllMocks();
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
});

it("shows the created channel immediately, before the refetch resolves", async () => {
// Seed the list with one existing channel.
mockClient.getDesktopFileSystemChannels.mockResolvedValue([
folder("1", "alpha"),
]);

const list = renderHook(() => useChannels(), { wrapper });
await waitFor(() => expect(list.result.current.isLoading).toBe(false));
expect(list.result.current.channels.map((c) => c.name)).toEqual(["alpha"]);

// Make the create return the new channel, but hang any subsequent refetch
// so we can prove the list updates without waiting on it.
const created = folder("2", "beta");
mockClient.createDesktopFileSystemChannel.mockResolvedValue(created);
mockClient.getDesktopFileSystemChannels.mockReturnValue(
new Promise(() => {}),
);

const mutations = renderHook(() => useChannelMutations(), { wrapper });
await act(async () => {
await mutations.result.current.createChannel("beta");
});

// The new channel is present from the optimistic cache write, sorted
// alphabetically alongside the existing one — without the hung refetch
// having resolved.
await waitFor(() =>
expect(list.result.current.channels.map((c) => c.name)).toEqual([
"alpha",
"beta",
]),
);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
});

it("does not duplicate a channel the poll already landed", async () => {
// The poll has already surfaced the channel we're about to create.
const existing = folder("1", "alpha");
mockClient.getDesktopFileSystemChannels.mockResolvedValue([existing]);

const list = renderHook(() => useChannels(), { wrapper });
await waitFor(() => expect(list.result.current.isLoading).toBe(false));
expect(list.result.current.channels.map((c) => c.id)).toEqual(["1"]);

// Create returns the same id; hang the refetch so only the optimistic
// cache write is exercised.
mockClient.createDesktopFileSystemChannel.mockResolvedValue(existing);
mockClient.getDesktopFileSystemChannels.mockReturnValue(
new Promise(() => {}),
);

const mutations = renderHook(() => useChannelMutations(), { wrapper });
await act(async () => {
await mutations.result.current.createChannel("alpha");
});

// The duplicate-id guard keeps the list at one entry.
expect(list.result.current.channels.map((c) => c.id)).toEqual(["1"]);
});
});
17 changes: 15 additions & 2 deletions packages/ui/src/features/canvas/hooks/useChannels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function useChannels(options?: { enabled?: boolean }): {
} {
const query = useAuthenticatedQuery<Schemas.FileSystem[]>(
CHANNELS_QUERY_KEY,
(client) => client.getDesktopFileSystem(),
(client) => client.getDesktopFileSystemChannels(),
{
enabled: options?.enabled ?? true,
refetchInterval: CHANNELS_POLL_INTERVAL_MS,
Expand Down Expand Up @@ -56,7 +56,20 @@ export function useChannelMutations() {
if (!client) throw new Error("Not authenticated");
return client.createDesktopFileSystemChannel(name);
},
onSuccess: invalidate,
onSuccess: (newFs) => {
// Insert the created channel into the cache immediately so the sidebar
// updates the instant the POST resolves, rather than waiting on the
// paginated refetch that `invalidate` triggers.
queryClient.setQueryData<Schemas.FileSystem[]>(
CHANNELS_QUERY_KEY,
(old) => {
if (!old) return [newFs];
if (old.some((fs) => fs.id === newFs.id)) return old;
return [...old, newFs];
},
);
invalidate();
},
});

const deleteMutation = useMutation({
Expand Down
Loading