Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit db03002

Browse files
authored
fix(canvas): don't show "No matches" while the space sidebar is loading
The list's render branches were independent conditions rather than a mutually exclusive chain, so a cold load with a filter active drew the skeleton and the Recent section's "No matches" empty state at the same time. The list now has one loading state covering pinned and recent together: until items arrive, a single skeleton speaks for the whole list, and a zero-length result is only reported as empty once loading settles. Also fixes "No matches" flashing while items are still arriving from the other queries, and the two empty states stacking when a filter or search is set on an empty space. Generated-By: PostHog Code Task-Id: 3bebbb5d-6e22-46c9-bacc-1c6d60bc9539
1 parent d153078 commit db03002

2 files changed

Lines changed: 211 additions & 5 deletions

File tree

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import type { ChannelItemModel } from "@posthog/core/canvas/channelItems";
2+
import { Theme } from "@radix-ui/themes";
3+
import { render, screen } from "@testing-library/react";
4+
import userEvent from "@testing-library/user-event";
5+
import { beforeEach, describe, expect, it, vi } from "vitest";
6+
7+
const mocks = vi.hoisted(() => ({
8+
items: [] as ChannelItemModel[],
9+
isLoading: false,
10+
channelMissing: false,
11+
}));
12+
13+
vi.mock("@posthog/ui/features/canvas/hooks/useChannelItems", () => ({
14+
useChannelItems: () => ({
15+
items: mocks.items,
16+
actions: { open: vi.fn(), togglePin: vi.fn(), archive: vi.fn() },
17+
me: { uuid: "me-uuid" },
18+
isLoading: mocks.isLoading,
19+
channelMissing: mocks.channelMissing,
20+
}),
21+
}));
22+
vi.mock("@posthog/ui/features/feature-flags/useFeatureFlag", () => ({
23+
useFeatureFlag: () => false,
24+
}));
25+
vi.mock("@tanstack/react-router", () => ({
26+
useNavigate: () => vi.fn(),
27+
useRouterState: () => "/website/channel-1",
28+
}));
29+
30+
// Both mount their own query stacks; this suite is about the list's own
31+
// loading-vs-empty decisions.
32+
vi.mock("@posthog/ui/features/canvas/components/ChannelBackRow", () => ({
33+
ChannelBackRow: () => null,
34+
}));
35+
vi.mock("@posthog/ui/features/canvas/components/ChannelsFab", () => ({
36+
ChannelsFab: () => null,
37+
}));
38+
39+
// The row context menu's hooks reach for a QueryClient and the DI container,
40+
// neither of which a unit test has. Stubbed at the module boundary, as
41+
// WebsiteLayout.test.tsx does for the same reason.
42+
vi.mock("@posthog/ui/features/tasks/useTaskContextMenu", () => ({
43+
useTaskContextMenu: () => ({
44+
showContextMenu: vi.fn(),
45+
editingTaskId: null,
46+
setEditingTaskId: vi.fn(),
47+
}),
48+
}));
49+
vi.mock("@posthog/ui/features/tasks/useTaskMutations", () => ({
50+
useRenameTask: () => ({ renameTask: vi.fn() }),
51+
}));
52+
vi.mock("@posthog/ui/features/tasks/useTasks", () => ({
53+
useTasks: () => ({ data: [] }),
54+
}));
55+
56+
import { ChannelSidebar } from "./ChannelSidebar";
57+
58+
function item(overrides: Partial<ChannelItemModel> = {}): ChannelItemModel {
59+
return {
60+
key: "task:task-1",
61+
kind: "task",
62+
id: "task-1",
63+
title: "Investigate signup drop-off",
64+
ts: Date.parse("2026-07-17T12:00:00.000Z"),
65+
pinned: false,
66+
rawStatus: null,
67+
authorUser: null,
68+
authorName: "Someone else",
69+
// Not the viewer, so filtering to "Me" leaves nothing.
70+
authorUuid: "someone-else-uuid",
71+
templateId: null,
72+
...overrides,
73+
};
74+
}
75+
76+
// A fresh element per render: React bails out of re-rendering an element it has
77+
// already seen by reference, and these tests change the hook's answer between
78+
// renders rather than the props.
79+
const sidebar = () => (
80+
<Theme>
81+
<ChannelSidebar channelId="channel-1" />
82+
</Theme>
83+
);
84+
85+
function renderSidebar() {
86+
return render(sidebar());
87+
}
88+
89+
describe("ChannelSidebar", () => {
90+
beforeEach(() => {
91+
mocks.items = [];
92+
mocks.isLoading = false;
93+
mocks.channelMissing = false;
94+
});
95+
96+
it.each([
97+
{
98+
what: "nothing has arrived yet",
99+
state: { items: [], isLoading: true },
100+
shown: [] as string[],
101+
hidden: ["Recent", "No matches", "Nothing here yet"],
102+
},
103+
{
104+
what: "the space is settled and genuinely empty",
105+
state: { items: [], isLoading: false },
106+
shown: ["Nothing here yet"],
107+
hidden: ["Recent", "No matches"],
108+
},
109+
{
110+
what: "the space is settled with items",
111+
state: { items: [item()], isLoading: false },
112+
shown: ["Recent", "Investigate signup drop-off"],
113+
hidden: ["No matches", "Nothing here yet"],
114+
},
115+
])("shows one state when $what", ({ state, shown, hidden }) => {
116+
mocks.items = state.items;
117+
mocks.isLoading = state.isLoading;
118+
119+
const { container } = renderSidebar();
120+
121+
for (const text of shown) {
122+
expect(screen.getByText(text)).toBeInTheDocument();
123+
}
124+
for (const text of hidden) {
125+
expect(screen.queryByText(text)).not.toBeInTheDocument();
126+
}
127+
expect(
128+
container.querySelector("[aria-busy]")?.getAttribute("aria-busy"),
129+
).toBe(String(state.isLoading));
130+
});
131+
132+
it("doesn't call a cold load 'no matches' while a filter is active", async () => {
133+
const user = userEvent.setup();
134+
mocks.items = [item()];
135+
const { rerender } = renderSidebar();
136+
137+
// Filter down to the viewer's own items; this one is someone else's, so the
138+
// settled list really has no matches.
139+
await user.click(screen.getByRole("button", { name: "Filter" }));
140+
await user.click(await screen.findByRole("menuitemradio", { name: "Me" }));
141+
expect(screen.getByText("No matches")).toBeInTheDocument();
142+
143+
// Reloading the space empties the list again — that isn't a verdict.
144+
mocks.items = [];
145+
mocks.isLoading = true;
146+
rerender(sidebar());
147+
148+
expect(screen.queryByText("No matches")).not.toBeInTheDocument();
149+
expect(screen.queryByText("Nothing here yet")).not.toBeInTheDocument();
150+
});
151+
152+
it("shows a single empty state when the last item goes away under a search", async () => {
153+
const user = userEvent.setup();
154+
mocks.items = [item()];
155+
const { rerender } = renderSidebar();
156+
157+
await user.click(screen.getByRole("button", { name: "Search" }));
158+
mocks.items = [];
159+
rerender(sidebar());
160+
161+
expect(screen.getByText("No matches")).toBeInTheDocument();
162+
expect(screen.queryByText("Nothing here yet")).not.toBeInTheDocument();
163+
});
164+
});

packages/ui/src/features/canvas/components/ChannelSidebar.tsx

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,34 @@ function ChannelItemsSkeleton() {
195195
);
196196
}
197197

198+
/** `ready` is the list itself, whether or not the filters leave any rows in it. */
199+
type ListState = "unavailable" | "loading" | "empty" | "ready";
200+
201+
/**
202+
* What the list shows, decided in one place. These were four conditions spread
203+
* across the render tree, which let a cold load draw the skeleton and the "No
204+
* matches" empty state at the same time.
205+
*
206+
* `narrowed` — a filter, or an open search box — is what makes no items mean
207+
* "nothing matches" rather than "nothing here".
208+
*/
209+
function listStateOf({
210+
channelMissing,
211+
isLoading,
212+
itemCount,
213+
narrowed,
214+
}: {
215+
channelMissing: boolean;
216+
isLoading: boolean;
217+
itemCount: number;
218+
narrowed: boolean;
219+
}): ListState {
220+
if (channelMissing) return "unavailable";
221+
if (isLoading && itemCount === 0) return "loading";
222+
if (itemCount === 0 && !narrowed) return "empty";
223+
return "ready";
224+
}
225+
198226
/**
199227
* The channel pane of the sidebar slider: the way back to the channel list,
200228
* the channel's sections, then its pinned and recent tasks & canvases.
@@ -246,6 +274,20 @@ export function ChannelSidebar({ channelId }: { channelId: string }) {
246274
[items, query, createdByFilter, statusFilter, me],
247275
);
248276

277+
const narrowed = filtersActive || searchOpen;
278+
const listState = listStateOf({
279+
channelMissing,
280+
isLoading,
281+
itemCount: items.length,
282+
narrowed,
283+
});
284+
// The list's two sections, which only exist once there are items. With
285+
// everything pinned there's nothing left to list — but keep the header while
286+
// it's narrowed, so you can undo whatever emptied it.
287+
const showPinned = listState === "ready" && pinnedItems.length > 0;
288+
const showRecent =
289+
listState === "ready" && (items.some((i) => !i.pinned) || narrowed);
290+
249291
const taskRow = (item: (typeof items)[number]) => (
250292
<ChannelItemRow
251293
key={item.key}
@@ -362,9 +404,9 @@ export function ChannelSidebar({ channelId }: { channelId: string }) {
362404
aria-busy={isLoading}
363405
className="scroll-mask-4 h-full overflow-y-auto px-2 pb-2"
364406
>
365-
{isLoading && items.length === 0 && <ChannelItemsSkeleton />}
407+
{listState === "loading" && <ChannelItemsSkeleton />}
366408

367-
{channelMissing && (
409+
{listState === "unavailable" && (
368410
<Empty className="border-0 py-6">
369411
<EmptyHeader>
370412
<EmptyMedia variant="icon">
@@ -378,7 +420,7 @@ export function ChannelSidebar({ channelId }: { channelId: string }) {
378420
</Empty>
379421
)}
380422

381-
{pinnedItems.length > 0 && (
423+
{showPinned && (
382424
<>
383425
<MenuLabel>Pinned</MenuLabel>
384426
<div className="flex flex-col gap-px">
@@ -387,7 +429,7 @@ export function ChannelSidebar({ channelId }: { channelId: string }) {
387429
</>
388430
)}
389431

390-
{(items.some((i) => !i.pinned) || filtersActive || searchOpen) && (
432+
{showRecent && (
391433
<>
392434
<RecentSectionHeader
393435
searchOpen={searchOpen}
@@ -423,7 +465,7 @@ export function ChannelSidebar({ channelId }: { channelId: string }) {
423465
</>
424466
)}
425467

426-
{!isLoading && !channelMissing && items.length === 0 && (
468+
{listState === "empty" && (
427469
<Empty className="border-0 py-6">
428470
<EmptyHeader>
429471
<EmptyMedia variant="icon">

0 commit comments

Comments
 (0)