Skip to content

Speed up channel/canvas/task loading on app reload#2964

Draft
raquelmsmith wants to merge 3 commits into
mainfrom
posthog-code/canvas-reload-caching
Draft

Speed up channel/canvas/task loading on app reload#2964
raquelmsmith wants to merge 3 commits into
mainfrom
posthog-code/canvas-reload-caching

Conversation

@raquelmsmith

Copy link
Copy Markdown
Member

Problem

Reloading the Channels (project-bluebird) space felt slow. On every reload the
channel sidebar, a channel's canvases, and its filed tasks all blanked out and
refetched from scratch — the in-memory query cache is destroyed on reload and
nothing is shown until the network resolves. On top of that, each per-channel
list made a redundant extra round-trip to resolve the channel's folder path.

Why: make the Channels space feel instant on reload instead of waiting on a
cold cache + chatty round-trips. (Backend/Redis wouldn't help here — the cost is
the cold cache and the round-trip waterfall from the desktop app, not backend
compute, so the fix is client-side.)

Changes

  • Persist the canvas query cache across reloads (stale-while-revalidate). A
    shared PersistQueryClientProvider (desktop: async electron-store persister;
    web: localStorage) restores channels/dashboards/channel-tasks from disk
    instantly, then refetches in the background. Persistence is scoped to canvas
    queries by key, gcTime is raised to match the persist maxAge, and the
    on-disk blob is wiped on logout/project-switch (via clearAuthScopedQueries)
    so project-scoped data never leaks across sessions.
  • Drop a redundant per-channel round-trip. dashboards.list /
    channelTasks.list resolved the channel's path with an extra getEntry
    before listing, even though the client already has it from useChannels.
    Thread the known channelPath through hook → router → service and skip the
    resolve (also halves useTaskChannelMap's fan-out from N×2 to N).
  • Tune staleness. useChannels now matches its 30s poll, and the web
    QueryClient gets the same defaults as desktop.

How did you test this?

  • Unit tests (ran locally, passing): service list(channelId, channelPath) skips
    getEntry when a path is supplied and falls back when it isn't
    (dashboardsService.test.ts, new channelTasksService.test.ts); the persist
    allowlist predicate (queryPersistence.test.ts).
  • typecheck clean on the changed packages/apps (the only remaining error is a
    pre-existing sonner import in FolderPicker.tsx, unrelated to this change).
  • biome check clean on all changed files; host-boundary check reports no new
    violations.
  • Not yet manually verified in the running app — please pull and reload the
    Channels space to confirm the instant-from-cache paint.

Automatic notifications

  • Publish to changelog?
  • Alert Sales and Marketing teams?

Created with PostHog Code

Reloading the Channels (project-bluebird) space was slow: channels, their
canvases, and filed tasks all blanked out and refetched from scratch.

Three changes:

- Persist the canvas query cache across reloads (stale-while-revalidate). A
  shared PersistQueryClientProvider restores channels/dashboards/channel-tasks
  from disk instantly, then refetches in the background. Persistence is scoped
  to canvas queries by key, gcTime is raised to match the persist maxAge, and
  the on-disk blob is wiped on logout/project-switch (via clearAuthScopedQueries)
  so project-scoped data can't leak across sessions.
- Drop a redundant per-channel round-trip: dashboards.list / channelTasks.list
  resolved the channel's path via an extra getEntry before listing, even though
  the client already has it from useChannels. Thread the known channelPath
  through hook → router → service and skip the resolve.
- Tune staleTime: useChannels now matches its 30s poll, and the web QueryClient
  gets the same defaults as desktop (staleTime/gcTime/refetchOnWindowFocus).

Generated-By: PostHog Code
Task-Id: dc505d33-c219-41c9-ac6b-192caf7730ea
@github-actions

github-actions Bot commented Jun 27, 2026

Copy link
Copy Markdown

React Doctor found no issues in the changed files. 🎉

Reviewed by React Doctor for commit 532ab46.

@greptile-apps

greptile-apps Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Comments Outside Diff (1)

  1. pnpm-lock.yaml, line 882-883 (link)

    P1 Peer-dependency version mismatch creates dual query-core builds

    @tanstack/react-query-persist-client@5.101.0 declares @tanstack/react-query: ^5.101.0 as a peer, but the lock file satisfies it with 5.90.20. As a result, query-core@5.101.0 (pulled in by query-persist-client-core) and query-core@5.90.20 (pulled in by react-query) are both present in the tree. PersistQueryClientProvider's dehydration machinery uses the 5.101.0 copy of dehydrate against a QueryClient whose internal Query objects are 5.90.20; if those two versions have diverged in the properties dehydrate accesses (e.g., options.gcTime, internal state shape), the persist blob could be written incorrectly and silently dropped on restore. The code comment in queryPersistence.ts acknowledges the risk at the TypeScript level and applies a structural-type workaround, but that doesn't protect against a runtime representation difference.

    The catalog in pnpm-workspace.yaml pins @tanstack/react-query: ^5.90.2 while the new persist packages resolved to 5.101.x. To resolve the mismatch, bump the catalog entry for @tanstack/react-query to ^5.101.0 (and re-lock), or pin all persist packages to a 5.90.x release that correctly peers with 5.90.20.

Reviews (1): Last reviewed commit: "Speed up channel/canvas/task loading on ..." | Re-trigger Greptile

Comment on lines +54 to +56
const { channels } = useChannels();
const channelPath = channels.find((c) => c.id === channelId)?.path;
const { dashboards, isLoading } = useDashboards(channelId, channelPath);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Double-fetch and loading flash on cold start from channelPath key change

On a cold start (no persisted cache), channels is initially [], so channelPath is undefined and useDashboards is called with key { channelId, channelPath: undefined }. Because useChannels() and useDashboards() both fire simultaneously, channels typically resolve first (no extra path-resolve round-trip). The moment channels land, channelPath changes from undefined to the real path and useDashboards switches to a new cache key { channelId, channelPath: "Channels/…" }. React Query has no data for that new key, so isLoading flips back to true, and since line 63 returns null while loading, the dashboards grid disappears and re-renders after the second fetch. This extra render and flash only occur on cold start, not on the persisted-cache reload the PR optimises for, but it is still a regression versus the pre-PR behaviour (single fetch, one loading phase).

Comment on lines +55 to +65
let activePersister: Persister | null = null;

/**
* Wipe the on-disk query cache. Called on logout and project switch (via
* clearAuthScopedQueries) so persisted, project-scoped canvas data never
* outlives the session that wrote it.
*/
export async function removePersistedCache(): Promise<void> {
await activePersister?.removeClient();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Module-level mutable singleton leaves a gap in the logout wipe

activePersister is set lazily by buildCanvasPersistOptions, which is called as a module-level side-effect in Providers.tsx. If clearAuthScopedQueries (and therefore removePersistedCache) is ever called before Providers has mounted — for example in a unit test, a server-side render path, or during an early auth error before the provider tree is assembled — activePersister is still null and the void removePersistedCache() call silently no-ops, leaving the on-disk blob intact. A project switch or logout in that window would not wipe the cache it was supposed to wipe.

Comment on lines +12 to +62
describe("shouldPersistCanvasQuery", () => {
it("persists the channels list", () => {
expect(shouldPersistCanvasQuery(fakeQuery(["canvas-channels"]))).toBe(true);
});

it("persists tRPC dashboards.list and dashboards.get", () => {
expect(
shouldPersistCanvasQuery(
fakeQuery([["dashboards", "list"], { input: {}, type: "query" }]),
),
).toBe(true);
expect(
shouldPersistCanvasQuery(
fakeQuery([["dashboards", "get"], { input: {}, type: "query" }]),
),
).toBe(true);
});

it("persists tRPC channelTasks.list", () => {
expect(
shouldPersistCanvasQuery(
fakeQuery([["channelTasks", "list"], { input: {}, type: "query" }]),
),
).toBe(true);
});

it("does not persist non-canvas queries", () => {
expect(
shouldPersistCanvasQuery(fakeQuery(["auth", "current-user", "us:2"])),
).toBe(false);
expect(
shouldPersistCanvasQuery(
fakeQuery([["sessions", "list"], { input: {}, type: "query" }]),
),
).toBe(false);
expect(
shouldPersistCanvasQuery(
fakeQuery([["dashboards", "saveFreeform"], { input: {} }]),
),
).toBe(false);
});

it("does not persist queries that have not succeeded", () => {
expect(
shouldPersistCanvasQuery(fakeQuery(["canvas-channels"], "pending")),
).toBe(false);
expect(
shouldPersistCanvasQuery(fakeQuery(["canvas-channels"], "error")),
).toBe(false);
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Tests should use a parameterised format (it.each)

The team rule is to always prefer parameterised tests. The shouldPersistCanvasQuery suite has a natural table structure (query key → expected boolean) that maps directly to it.each. The current individual it blocks duplicate the call to shouldPersistCanvasQuery and fakeQuery per case. The same applies to the new channelTasksService.test.ts tests that could be parameterised over (channelPath provided? → getEntry called?) scenarios.

Context Used: Do not attempt to comment on incorrect alphabetica... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

… tests

Greptile review follow-ups:

- Pin the persist packages to react-query's resolved version (5.90.20) and add a
  pnpm override for @tanstack/query-core so the whole tree shares a single
  query-core. Previously the persist packages floated to 5.101.x and pulled a
  second query-core, which could make the persist/restore machinery operate on
  mismatched Query internals.
- WebsiteDashboardsIndex: hold the dashboards query until channels have loaded so
  it fires once with the resolved channelPath. Avoids a cold-start double-fetch
  and loading flash from the query key changing when channelPath resolves.
- Parameterise the new tests with it.each (predicate cases; service
  path-resolution cases) per the team convention.

Generated-By: PostHog Code
Task-Id: dc505d33-c219-41c9-ac6b-192caf7730ea
Unblocks local build/typecheck on this branch, which inherits the broken
`sonner` import from main (FolderPicker imports an undeclared dependency). This
is the same one-line fix as hotfix PR #2965; once that merges to main and main
is merged back in, this resolves identically (no conflict). Kept here so the
branch builds locally in the meantime.

Generated-By: PostHog Code
Task-Id: dc505d33-c219-41c9-ac6b-192caf7730ea
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant