From 24ce0dc6e7ceed3d65893855ce24dc4c04ec14bf Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Sat, 27 Jun 2026 15:42:33 -0700 Subject: [PATCH 1/3] Speed up channel/canvas/task loading on app reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/code/package.json | 2 + .../src/renderer/components/Providers.tsx | 19 +++-- apps/code/src/renderer/utils/queryClient.ts | 4 + .../code/src/renderer/utils/queryPersister.ts | 17 ++++ apps/web/package.json | 2 + apps/web/src/Providers.tsx | 13 ++- apps/web/src/web-container.ts | 12 ++- apps/web/src/web-persister.ts | 9 +++ .../core/src/canvas/channelTaskSchemas.ts | 5 ++ .../src/canvas/channelTasksService.test.ts | 69 ++++++++++++++++ .../core/src/canvas/channelTasksService.ts | 9 ++- packages/core/src/canvas/dashboardSchemas.ts | 9 ++- .../core/src/canvas/dashboardsService.test.ts | 29 ++++++- packages/core/src/canvas/dashboardsService.ts | 9 ++- packages/core/src/canvas/services.ts | 8 +- .../src/routers/channel-tasks.router.ts | 2 +- .../src/routers/dashboards.router.ts | 2 +- packages/ui/package.json | 2 + packages/ui/src/features/auth/authQueries.ts | 4 + .../canvas/components/ChannelsList.tsx | 14 +++- .../components/WebsiteDashboardsIndex.tsx | 8 +- .../features/canvas/hooks/useChannelTasks.ts | 27 +++++-- .../src/features/canvas/hooks/useChannels.ts | 3 + .../features/canvas/hooks/useDashboards.ts | 30 +++++-- .../canvas/hooks/useTaskChannelMap.ts | 5 +- .../ui/src/shell/queryPersistence.test.ts | 62 ++++++++++++++ packages/ui/src/shell/queryPersistence.ts | 81 +++++++++++++++++++ pnpm-lock.yaml | 58 +++++++++++++ pnpm-workspace.yaml | 3 + 29 files changed, 471 insertions(+), 46 deletions(-) create mode 100644 apps/code/src/renderer/utils/queryPersister.ts create mode 100644 apps/web/src/web-persister.ts create mode 100644 packages/core/src/canvas/channelTasksService.test.ts create mode 100644 packages/ui/src/shell/queryPersistence.test.ts create mode 100644 packages/ui/src/shell/queryPersistence.ts diff --git a/apps/code/package.json b/apps/code/package.json index 19ca6a9abc..ec45f80cb7 100644 --- a/apps/code/package.json +++ b/apps/code/package.json @@ -118,7 +118,9 @@ "@posthog/workspace-server": "workspace:*", "@radix-ui/themes": "^3.2.1", "@tailwindcss/vite": "^4.2.2", + "@tanstack/query-async-storage-persister": "^5.90.2", "@tanstack/react-query": "^5.90.2", + "@tanstack/react-query-persist-client": "^5.90.2", "@tanstack/router-plugin": "^1.95.0", "@trpc/client": "^11.12.0", "@trpc/server": "^11.12.0", diff --git a/apps/code/src/renderer/components/Providers.tsx b/apps/code/src/renderer/components/Providers.tsx index d7c50d58d4..6217c1e169 100644 --- a/apps/code/src/renderer/components/Providers.tsx +++ b/apps/code/src/renderer/components/Providers.tsx @@ -1,5 +1,6 @@ import { HostTRPCProvider } from "@posthog/host-router/react"; import { ThemeWrapper } from "@posthog/ui/primitives/ThemeWrapper"; +import { buildCanvasPersistOptions } from "@posthog/ui/shell/queryPersistence"; import { WorkspaceClientProvider } from "@posthog/workspace-client/provider"; import { hostTrpcClient, @@ -7,18 +8,17 @@ import { trpcClient, useTRPC, } from "@renderer/trpc/client"; -import { - QueryClientProvider, - useMutation, - useQuery, - useQueryClient, -} from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; import { useSubscription } from "@trpc/tanstack-react-query"; import { queryClient } from "@utils/queryClient"; +import { queryPersister } from "@utils/queryPersister"; import type React from "react"; import { useCallback, useState } from "react"; import { HotkeysProvider } from "react-hotkeys-hook"; +const persistOptions = buildCanvasPersistOptions(queryPersister); + function WorkspaceServerErrorBanner({ onRetry, disabled, @@ -107,7 +107,10 @@ export const Providers: React.FC<{ children: React.ReactNode }> = ({ }) => { return ( - + = ({ - + ); }; diff --git a/apps/code/src/renderer/utils/queryClient.ts b/apps/code/src/renderer/utils/queryClient.ts index aa32898722..37e2f387fe 100644 --- a/apps/code/src/renderer/utils/queryClient.ts +++ b/apps/code/src/renderer/utils/queryClient.ts @@ -5,6 +5,10 @@ export const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60 * 5, + // Keep entries around for a day so the persisted cache can be restored on + // reload — PersistQueryClientProvider only restores a query whose gcTime + // is >= the persist maxAge (see queryPersistence.CANVAS_PERSIST_MAX_AGE). + gcTime: 1000 * 60 * 60 * 24, refetchOnWindowFocus: true, }, }, diff --git a/apps/code/src/renderer/utils/queryPersister.ts b/apps/code/src/renderer/utils/queryPersister.ts new file mode 100644 index 0000000000..c63d9a894c --- /dev/null +++ b/apps/code/src/renderer/utils/queryPersister.ts @@ -0,0 +1,17 @@ +import { CANVAS_PERSIST_KEY } from "@posthog/ui/shell/queryPersistence"; +import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister"; +import { trpcClient } from "../trpc"; + +// Persist the query cache through the same electron-backed secure store that +// holds the renderer's other persisted state. We talk to the raw string-in/out +// secureStore backend directly (not the createJSONStorage `electronStorage` +// wrapper) so the persister owns serialization without double-encoding. +export const queryPersister = createAsyncStoragePersister({ + key: CANVAS_PERSIST_KEY, + storage: { + getItem: (key) => trpcClient.secureStore.getItem.query({ key }), + setItem: (key, value) => + trpcClient.secureStore.setItem.query({ key, value }), + removeItem: (key) => trpcClient.secureStore.removeItem.query({ key }), + }, +}); diff --git a/apps/web/package.json b/apps/web/package.json index 29a93f64a3..14b249c0eb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -18,7 +18,9 @@ "@posthog/shared": "workspace:*", "@posthog/ui": "workspace:*", "@posthog/workspace-client": "workspace:*", + "@tanstack/query-sync-storage-persister": "^5.90.2", "@tanstack/react-query": "^5.90.2", + "@tanstack/react-query-persist-client": "^5.90.2", "@trpc/client": "^11.12.0", "@trpc/tanstack-react-query": "^11.12.0", "inversify": "^7.10.6", diff --git a/apps/web/src/Providers.tsx b/apps/web/src/Providers.tsx index 207a20a293..531d543a2e 100644 --- a/apps/web/src/Providers.tsx +++ b/apps/web/src/Providers.tsx @@ -1,11 +1,15 @@ import { HostTRPCProvider } from "@posthog/host-router/react"; import { ThemeWrapper } from "@posthog/ui/primitives/ThemeWrapper"; -import { QueryClientProvider } from "@tanstack/react-query"; +import { buildCanvasPersistOptions } from "@posthog/ui/shell/queryPersistence"; +import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; import type React from "react"; import { HotkeysProvider } from "react-hotkeys-hook"; import { queryClient } from "./web-container"; +import { queryPersister } from "./web-persister"; import { hostTrpcClient } from "./web-trpc"; +const persistOptions = buildCanvasPersistOptions(queryPersister); + // Web transport wiring — the per-host counterpart of apps/code's Providers.tsx. // @posthog/ui consumes the HOST router context (useHostTRPCClient), so web only // needs HostTRPCProvider over the HTTP client. No electron TrpcRouter context. @@ -15,11 +19,14 @@ export const Providers: React.FC<{ children: React.ReactNode }> = ({ }) => { return ( - + {children} - + ); }; diff --git a/apps/web/src/web-container.ts b/apps/web/src/web-container.ts index d871b48534..392649b8be 100644 --- a/apps/web/src/web-container.ts +++ b/apps/web/src/web-container.ts @@ -45,7 +45,17 @@ interface WebBindings { [MCP_SANDBOX_PROXY_URL]: McpSandboxProxyUrlProvider; } -export const queryClient = new QueryClient(); +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, + // Match the persist maxAge so PersistQueryClientProvider can restore the + // canvas cache on reload (see queryPersistence.CANVAS_PERSIST_MAX_AGE). + gcTime: 1000 * 60 * 60 * 24, + refetchOnWindowFocus: true, + }, + }, +}); export const container = new TypedContainer({ defaultScope: "Singleton", diff --git a/apps/web/src/web-persister.ts b/apps/web/src/web-persister.ts new file mode 100644 index 0000000000..b68966fad5 --- /dev/null +++ b/apps/web/src/web-persister.ts @@ -0,0 +1,9 @@ +import { CANVAS_PERSIST_KEY } from "@posthog/ui/shell/queryPersistence"; +import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister"; + +// Web persists the query cache to localStorage (synchronous, always available +// in the browser). The desktop host uses an async electron-backed persister. +export const queryPersister = createSyncStoragePersister({ + key: CANVAS_PERSIST_KEY, + storage: window.localStorage, +}); diff --git a/packages/core/src/canvas/channelTaskSchemas.ts b/packages/core/src/canvas/channelTaskSchemas.ts index b04cb25b3b..4f03332fac 100644 --- a/packages/core/src/canvas/channelTaskSchemas.ts +++ b/packages/core/src/canvas/channelTaskSchemas.ts @@ -10,6 +10,11 @@ export type ChannelTaskRecord = z.infer; export const listChannelTasksInput = z.object({ channelId: z.string().min(1), + // The channel folder's file-system path, if the caller already knows it (it + // rides on the channel row from `useChannels`). Lets the service skip the + // extra getEntry round-trip that resolves channelId → path. Optional so older + // callers and tests still work; the service falls back to resolving it. + channelPath: z.string().optional(), }); export const fileChannelTaskInput = z.object({ diff --git a/packages/core/src/canvas/channelTasksService.test.ts b/packages/core/src/canvas/channelTasksService.test.ts new file mode 100644 index 0000000000..c7eef34bd6 --- /dev/null +++ b/packages/core/src/canvas/channelTasksService.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; +import { ChannelTasksService } from "./channelTasksService"; +import type { DesktopFsClient, FsEntryBase } from "./desktopFsClient"; + +// A task FS row carrying a `ref` (the task id) under the channel folder, as the +// backend returns it from a parent-scoped list. +function taskRow(id: string, taskId: string, createdAt: string): FsEntryBase { + return { + id, + path: `Channels/chan-1/${id}`, + type: "task", + ref: taskId, + created_at: createdAt, + } as FsEntryBase; +} + +// A fake DesktopFsClient exposing the two methods `list` touches: getEntry (to +// resolve the channel folder path) and fetch (the parent-scoped list GET). +function fakeFs(rows: FsEntryBase[]) { + const fetch = vi.fn(async (_suffix: string) => ({ + ok: true, + status: 200, + json: async () => ({ results: rows }), + })); + const getEntry = vi.fn(async (id: string) => ({ + id, + path: `Channels/${id}`, + })); + const fs = { getEntry, fetch } as unknown as DesktopFsClient; + return { fs, fetch, getEntry }; +} + +describe("ChannelTasksService.list", () => { + it("uses a known channelPath without resolving it via getEntry", async () => { + const { fs, fetch, getEntry } = fakeFs([]); + const service = new ChannelTasksService(fs); + + await service.list("chan-1", "marketing/team"); + + // The path was supplied, so no getEntry round-trip; the list GET uses it. + expect(getEntry).not.toHaveBeenCalled(); + const [suffix] = fetch.mock.calls[0]; + expect(suffix).toContain(encodeURIComponent("marketing/team")); + expect(suffix).toContain("type=task"); + }); + + it("falls back to resolving the path via getEntry when none is given", async () => { + const { fs, getEntry } = fakeFs([]); + const service = new ChannelTasksService(fs); + + await service.list("chan-1"); + + expect(getEntry).toHaveBeenCalledTimes(1); + }); + + it("maps rows to records sorted by createdAt descending, dropping ref-less rows", async () => { + const { fs } = fakeFs([ + taskRow("a", "task-a", "2026-01-01T00:00:00Z"), + taskRow("c", "task-c", "2026-01-03T00:00:00Z"), + { id: "no-ref", path: "Channels/chan-1/x", type: "task" } as FsEntryBase, + taskRow("b", "task-b", "2026-01-02T00:00:00Z"), + ]); + const service = new ChannelTasksService(fs); + + const result = await service.list("chan-1", "Channels/chan-1"); + + expect(result.map((r) => r.taskId)).toEqual(["task-c", "task-b", "task-a"]); + }); +}); diff --git a/packages/core/src/canvas/channelTasksService.ts b/packages/core/src/canvas/channelTasksService.ts index 25c813fbe3..2c7d08b16a 100644 --- a/packages/core/src/canvas/channelTasksService.ts +++ b/packages/core/src/canvas/channelTasksService.ts @@ -25,8 +25,13 @@ export class ChannelTasksService { private readonly fs: DesktopFsClient, ) {} - async list(channelId: string): Promise { - const channelPath = await this.channelPath(channelId); + async list( + channelId: string, + knownChannelPath?: string, + ): Promise { + // The caller usually already knows the channel path (it rides on the channel + // row from useChannels), so accept it to skip the getEntry resolve. + const channelPath = knownChannelPath ?? (await this.channelPath(channelId)); const entries = await this.listUnderParent(channelPath); return entries .filter((e) => !!e.ref) diff --git a/packages/core/src/canvas/dashboardSchemas.ts b/packages/core/src/canvas/dashboardSchemas.ts index 0020ea9797..59d0fa653f 100644 --- a/packages/core/src/canvas/dashboardSchemas.ts +++ b/packages/core/src/canvas/dashboardSchemas.ts @@ -78,7 +78,14 @@ export const dashboardSummarySchema = z.object({ }); export type DashboardSummary = z.infer; -export const listDashboardsInput = z.object({ channelId: z.string().min(1) }); +export const listDashboardsInput = z.object({ + channelId: z.string().min(1), + // The channel folder's file-system path, if the caller already knows it (it + // rides on the channel row from `useChannels`). Lets the service skip the + // extra getEntry round-trip that resolves channelId → path. Optional so older + // callers and tests still work; the service falls back to resolving it. + channelPath: z.string().optional(), +}); export const createDashboardInput = z.object({ channelId: z.string().min(1), diff --git a/packages/core/src/canvas/dashboardsService.test.ts b/packages/core/src/canvas/dashboardsService.test.ts index 5d488ccdf5..cac8006f5c 100644 --- a/packages/core/src/canvas/dashboardsService.test.ts +++ b/packages/core/src/canvas/dashboardsService.test.ts @@ -30,11 +30,15 @@ function fakeFs(rows: FsEntryBase[]) { const listByQuery = vi.fn( async (_query: string, _errorLabel: string): Promise => rows, ); + const getEntry = vi.fn(async (id: string) => ({ + id, + path: `Channels/${id}`, + })); const fs = { - getEntry: async (id: string) => ({ id, path: `Channels/${id}` }), + getEntry, listByQuery, }; - return { fs: fs as unknown as DesktopFsClient, listByQuery }; + return { fs: fs as unknown as DesktopFsClient, listByQuery, getEntry }; } describe("DashboardsService.list", () => { @@ -51,6 +55,27 @@ describe("DashboardsService.list", () => { expect(query).toContain("type=dashboard"); }); + it("uses a known channelPath without resolving it via getEntry", async () => { + const { fs, listByQuery, getEntry } = fakeFs([]); + const service = new DashboardsService(fs, {} as never); + + await service.list("chan-1", "marketing/team"); + + // The path was supplied, so no getEntry round-trip; the list query uses it. + expect(getEntry).not.toHaveBeenCalled(); + const [query] = listByQuery.mock.calls[0]; + expect(query).toContain(encodeURIComponent("marketing/team")); + }); + + it("falls back to resolving the path via getEntry when none is given", async () => { + const { fs, getEntry } = fakeFs([]); + const service = new DashboardsService(fs, {} as never); + + await service.list("chan-1"); + + expect(getEntry).toHaveBeenCalledTimes(1); + }); + it("maps rows to summaries sorted by updatedAt descending", async () => { const { fs } = fakeFs([ dashboardRow("a", "Older", "chan-1", 100), diff --git a/packages/core/src/canvas/dashboardsService.ts b/packages/core/src/canvas/dashboardsService.ts index 59df11649f..6cc85f11bd 100644 --- a/packages/core/src/canvas/dashboardsService.ts +++ b/packages/core/src/canvas/dashboardsService.ts @@ -68,12 +68,17 @@ export class DashboardsService { return this.fs.getEntry(id, "dashboard"); } - async list(channelId: string): Promise { + async list( + channelId: string, + knownChannelPath?: string, + ): Promise { // Fetch only this channel's dashboards via a server-side filter // (`parent=&type=dashboard`) rather than walking the whole // project file system and filtering client-side. Dashboards are created as // direct children of the channel folder, so the parent filter matches them. - const channelPath = await this.channelPath(channelId); + // The caller usually already knows the channel path (it rides on the channel + // row from useChannels), so accept it to skip the getEntry resolve. + const channelPath = knownChannelPath ?? (await this.channelPath(channelId)); const entries = await this.fs.listByQuery( `parent=${encodeURIComponent(channelPath)}&type=${DASHBOARD_TYPE}`, "dashboards", diff --git a/packages/core/src/canvas/services.ts b/packages/core/src/canvas/services.ts index 3bda4048d9..9d4ddc4a57 100644 --- a/packages/core/src/canvas/services.ts +++ b/packages/core/src/canvas/services.ts @@ -26,7 +26,9 @@ export interface ICanvasTemplatesService { } export interface IDashboardsService { - list(channelId: string): Promise; + // channelPath is an optional optimization: when the caller already knows the + // channel folder's path, the service skips the getEntry round-trip. + list(channelId: string, channelPath?: string): Promise; get(id: string): Promise; create(input: { channelId: string; @@ -61,7 +63,9 @@ export interface ICanvasDataService { } export interface IChannelTasksService { - list(channelId: string): Promise; + // channelPath is an optional optimization: when the caller already knows the + // channel folder's path, the service skips the getEntry round-trip. + list(channelId: string, channelPath?: string): Promise; file(input: { channelId: string; taskId: string; diff --git a/packages/host-router/src/routers/channel-tasks.router.ts b/packages/host-router/src/routers/channel-tasks.router.ts index 19a895dcfc..091826b931 100644 --- a/packages/host-router/src/routers/channel-tasks.router.ts +++ b/packages/host-router/src/routers/channel-tasks.router.ts @@ -16,7 +16,7 @@ export const channelTasksRouter = router({ .query(({ ctx, input }) => ctx.container .get(CHANNEL_TASKS_SERVICE) - .list(input.channelId), + .list(input.channelId, input.channelPath), ), file: publicProcedure .input(fileChannelTaskInput) diff --git a/packages/host-router/src/routers/dashboards.router.ts b/packages/host-router/src/routers/dashboards.router.ts index b9e763bd11..34ba402fd4 100644 --- a/packages/host-router/src/routers/dashboards.router.ts +++ b/packages/host-router/src/routers/dashboards.router.ts @@ -21,7 +21,7 @@ export const dashboardsRouter = router({ .query(({ ctx, input }) => ctx.container .get(DASHBOARDS_SERVICE) - .list(input.channelId), + .list(input.channelId, input.channelPath), ), get: publicProcedure .input(dashboardIdInput) diff --git a/packages/ui/package.json b/packages/ui/package.json index 9df30c443f..fa973f34eb 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -115,6 +115,7 @@ "@posthog/quill": "catalog:", "@radix-ui/themes": "catalog:", "@tanstack/react-query": "catalog:", + "@tanstack/react-query-persist-client": "catalog:", "react": "catalog:", "react-dom": "catalog:" }, @@ -124,6 +125,7 @@ "@posthog/tsconfig": "workspace:*", "@radix-ui/themes": "catalog:", "@tanstack/react-query": "catalog:", + "@tanstack/react-query-persist-client": "catalog:", "@tanstack/router-generator": "catalog:", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", diff --git a/packages/ui/src/features/auth/authQueries.ts b/packages/ui/src/features/auth/authQueries.ts index a8f7d5d475..cf9ac2871a 100644 --- a/packages/ui/src/features/auth/authQueries.ts +++ b/packages/ui/src/features/auth/authQueries.ts @@ -8,6 +8,7 @@ import { IMPERATIVE_QUERY_CLIENT, type ImperativeQueryClient, } from "@posthog/ui/shell/queryClient"; +import { removePersistedCache } from "@posthog/ui/shell/queryPersistence"; import { ANONYMOUS_AUTH_STATE, getAuthIdentity, useAuthStore } from "./store"; export type { AuthState }; @@ -45,4 +46,7 @@ export function clearAuthScopedQueries(): void { queryClient().removeQueries({ predicate: (query) => query.meta?.authScoped === true, }); + // Also drop the on-disk query cache so persisted, project-scoped canvas data + // can't be restored into a different project/account on the next reload. + void removePersistedCache(); } diff --git a/packages/ui/src/features/canvas/components/ChannelsList.tsx b/packages/ui/src/features/canvas/components/ChannelsList.tsx index 625a45f763..5fc56e6e21 100644 --- a/packages/ui/src/features/canvas/components/ChannelsList.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsList.tsx @@ -1004,16 +1004,22 @@ function ChannelSection({ // Lazy: a channel's canvases and filed tasks are only fetched once it's // expanded, so the tree doesn't fire one query per channel on mount. - const { dashboards } = useDashboards(open ? channel.id : undefined); - const { tasks: filedTasks } = useChannelTasks(open ? channel.id : undefined); + const { dashboards } = useDashboards( + open ? channel.id : undefined, + channel.path, + ); + const { tasks: filedTasks } = useChannelTasks( + open ? channel.id : undefined, + channel.path, + ); // Warm both caches on hover/focus so the first expand is instant instead of // popping in after a cold fetch. No-ops once the data is fresh or loaded. const prefetchDashboards = usePrefetchDashboards(); const prefetchChannelTasks = usePrefetchChannelTasks(); const prefetchContents = () => { if (open) return; - prefetchDashboards(channel.id); - prefetchChannelTasks(channel.id); + prefetchDashboards(channel.id, channel.path); + prefetchChannelTasks(channel.id, channel.path); }; // Tasks are private to each user. A task filed by someone else won't be in // `tasks` (it isn't shared with me), so hide it rather than rendering an diff --git a/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx b/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx index e4f359ead2..ff5d74262b 100644 --- a/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx @@ -18,6 +18,7 @@ import { NewCanvasMenu } from "@posthog/ui/features/canvas/components/NewCanvasM import { FreeformCanvas } from "@posthog/ui/features/canvas/freeform/FreeformCanvas"; import { handleFreeformDataRequest } from "@posthog/ui/features/canvas/freeform/freeformDataBridge"; import { useCanvasTemplates } from "@posthog/ui/features/canvas/hooks/useCanvasTemplates"; +import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useDashboardMutations, useDashboards, @@ -47,7 +48,12 @@ const PREVIEW_VIEWPORT = { once: false, rootMargin: "400px 0px" } as const; // A channel's dashboards index: a grid of cards, each showing a scaled-down // live preview. Clicking a card opens the full dashboard. export function WebsiteDashboardsIndex({ channelId }: { channelId: string }) { - const { dashboards, isLoading } = useDashboards(channelId); + // Resolve the channel's path from the (already-loaded) channels list so the + // list query shares its cache key with the sidebar's and the service can skip + // the getEntry path-resolve round-trip. + const { channels } = useChannels(); + const channelPath = channels.find((c) => c.id === channelId)?.path; + const { dashboards, isLoading } = useDashboards(channelId, channelPath); // templateId -> display name, for the per-card badge ("Freeform (React)", …). // Falls back to the raw id for any template not in the registry. diff --git a/packages/ui/src/features/canvas/hooks/useChannelTasks.ts b/packages/ui/src/features/canvas/hooks/useChannelTasks.ts index 7a33ce6645..65908a7dd5 100644 --- a/packages/ui/src/features/canvas/hooks/useChannelTasks.ts +++ b/packages/ui/src/features/canvas/hooks/useChannelTasks.ts @@ -1,18 +1,26 @@ import type { ChannelTaskRecord } from "@posthog/core/canvas/channelTaskSchemas"; import { useHostTRPC } from "@posthog/host-router/react"; +import { AUTH_SCOPED_QUERY_META } from "@posthog/ui/features/auth/authQueries"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback } from "react"; -/** Tasks filed to a channel — backed by desktop_file_system rows. */ -export function useChannelTasks(channelId: string | undefined): { +/** + * Tasks filed to a channel — backed by desktop_file_system rows. Pass the + * channel's known `channelPath` (from useChannels) so the service can skip the + * getEntry round-trip that resolves the path from the id. + */ +export function useChannelTasks( + channelId: string | undefined, + channelPath?: string, +): { tasks: ChannelTaskRecord[]; isLoading: boolean; } { const trpc = useHostTRPC(); const { data, isLoading } = useQuery( trpc.channelTasks.list.queryOptions( - { channelId: channelId ?? "" }, - { enabled: !!channelId, staleTime: 5_000 }, + { channelId: channelId ?? "", channelPath }, + { enabled: !!channelId, staleTime: 5_000, meta: AUTH_SCOPED_QUERY_META }, ), ); return { tasks: data ?? [], isLoading }; @@ -23,15 +31,18 @@ export function useChannelTasks(channelId: string | undefined): { * so expanding the channel doesn't cold-fetch its tasks. Respects the same * staleTime, so it no-ops when the data is already fresh. */ -export function usePrefetchChannelTasks(): (channelId: string) => void { +export function usePrefetchChannelTasks(): ( + channelId: string, + channelPath?: string, +) => void { const trpc = useHostTRPC(); const queryClient = useQueryClient(); return useCallback( - (channelId: string) => { + (channelId: string, channelPath?: string) => { void queryClient.prefetchQuery( trpc.channelTasks.list.queryOptions( - { channelId }, - { staleTime: 5_000 }, + { channelId, channelPath }, + { staleTime: 5_000, meta: AUTH_SCOPED_QUERY_META }, ), ); }, diff --git a/packages/ui/src/features/canvas/hooks/useChannels.ts b/packages/ui/src/features/canvas/hooks/useChannels.ts index ba2e78945a..b6f80b668e 100644 --- a/packages/ui/src/features/canvas/hooks/useChannels.ts +++ b/packages/ui/src/features/canvas/hooks/useChannels.ts @@ -49,6 +49,9 @@ export function useChannels(options?: { enabled?: boolean }): { (client) => client.getDesktopFileSystemChannels(), { enabled: options?.enabled ?? true, + // Match the poll interval so a remount within the window serves the cached + // (or persisted) list instead of refetching and flashing a loading state. + staleTime: CHANNELS_POLL_INTERVAL_MS, refetchInterval: CHANNELS_POLL_INTERVAL_MS, }, ); diff --git a/packages/ui/src/features/canvas/hooks/useDashboards.ts b/packages/ui/src/features/canvas/hooks/useDashboards.ts index 5980e60709..44171f440e 100644 --- a/packages/ui/src/features/canvas/hooks/useDashboards.ts +++ b/packages/ui/src/features/canvas/hooks/useDashboards.ts @@ -4,6 +4,7 @@ import type { } from "@posthog/core/canvas/dashboardSchemas"; import type { FreeformVersion } from "@posthog/core/canvas/freeformSchemas"; import { useHostTRPC } from "@posthog/host-router/react"; +import { AUTH_SCOPED_QUERY_META } from "@posthog/ui/features/auth/authQueries"; import { useDashboardEditStore } from "@posthog/ui/features/canvas/stores/dashboardEditStore"; import { toast } from "@posthog/ui/primitives/toast"; import { logger } from "@posthog/ui/shell/logger"; @@ -24,16 +25,23 @@ export function isPlaceholderCanvasName(name: string): boolean { return trimmed === UNTITLED_CANVAS_NAME || trimmed === "Untitled dashboard"; } -/** Saved canvases for a channel (file-backed freeform React apps). */ -export function useDashboards(channelId: string | undefined): { +/** + * Saved canvases for a channel (file-backed freeform React apps). Pass the + * channel's known `channelPath` (from useChannels) so the service can skip the + * getEntry round-trip that resolves the path from the id. + */ +export function useDashboards( + channelId: string | undefined, + channelPath?: string, +): { dashboards: DashboardSummary[]; isLoading: boolean; } { const trpc = useHostTRPC(); const { data, isLoading } = useQuery( trpc.dashboards.list.queryOptions( - { channelId: channelId ?? "" }, - { enabled: !!channelId, staleTime: 5_000 }, + { channelId: channelId ?? "", channelPath }, + { enabled: !!channelId, staleTime: 5_000, meta: AUTH_SCOPED_QUERY_META }, ), ); return { dashboards: data ?? [], isLoading }; @@ -44,13 +52,19 @@ export function useDashboards(channelId: string | undefined): { * hover), so expanding the channel shows its canvases without a cold fetch. * Respects the same staleTime, so it no-ops when the data is already fresh. */ -export function usePrefetchDashboards(): (channelId: string) => void { +export function usePrefetchDashboards(): ( + channelId: string, + channelPath?: string, +) => void { const trpc = useHostTRPC(); const queryClient = useQueryClient(); return useCallback( - (channelId: string) => { + (channelId: string, channelPath?: string) => { void queryClient.prefetchQuery( - trpc.dashboards.list.queryOptions({ channelId }, { staleTime: 5_000 }), + trpc.dashboards.list.queryOptions( + { channelId, channelPath }, + { staleTime: 5_000, meta: AUTH_SCOPED_QUERY_META }, + ), ); }, [trpc, queryClient], @@ -67,7 +81,7 @@ export function useDashboard(id: string | undefined): { const { data, isLoading, isFetching } = useQuery( trpc.dashboards.get.queryOptions( { id: id ?? "" }, - { enabled: !!id, staleTime: 5_000 }, + { enabled: !!id, staleTime: 5_000, meta: AUTH_SCOPED_QUERY_META }, ), ); return { dashboard: data, isLoading, isFetching }; diff --git a/packages/ui/src/features/canvas/hooks/useTaskChannelMap.ts b/packages/ui/src/features/canvas/hooks/useTaskChannelMap.ts index 10cf6c6873..19b937e3a6 100644 --- a/packages/ui/src/features/canvas/hooks/useTaskChannelMap.ts +++ b/packages/ui/src/features/canvas/hooks/useTaskChannelMap.ts @@ -1,4 +1,5 @@ import { useHostTRPC } from "@posthog/host-router/react"; +import { AUTH_SCOPED_QUERY_META } from "@posthog/ui/features/auth/authQueries"; import type { Channel } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useQueries } from "@tanstack/react-query"; import { useMemo } from "react"; @@ -22,8 +23,8 @@ export function useTaskChannelMap( const results = useQueries({ queries: channels.map((channel) => trpc.channelTasks.list.queryOptions( - { channelId: channel.id }, - { enabled, staleTime: 5_000 }, + { channelId: channel.id, channelPath: channel.path }, + { enabled, staleTime: 5_000, meta: AUTH_SCOPED_QUERY_META }, ), ), }); diff --git a/packages/ui/src/shell/queryPersistence.test.ts b/packages/ui/src/shell/queryPersistence.test.ts new file mode 100644 index 0000000000..6fae34cad6 --- /dev/null +++ b/packages/ui/src/shell/queryPersistence.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { shouldPersistCanvasQuery } from "./queryPersistence"; + +type PredicateArg = Parameters[0]; + +// Minimal Query stand-in: the predicate only reads `queryKey` and +// `state.status`. +function fakeQuery(queryKey: unknown, status = "success"): PredicateArg { + return { queryKey, state: { status } } as unknown as PredicateArg; +} + +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); + }); +}); diff --git a/packages/ui/src/shell/queryPersistence.ts b/packages/ui/src/shell/queryPersistence.ts new file mode 100644 index 0000000000..727e468659 --- /dev/null +++ b/packages/ui/src/shell/queryPersistence.ts @@ -0,0 +1,81 @@ +import type { + Persister, + PersistQueryClientOptions, +} from "@tanstack/react-query-persist-client"; + +// Only the fields the predicate reads. Declared structurally (rather than +// importing `Query`) so it stays assignable to `shouldDehydrateQuery` even when +// react-query and the persist client resolve different copies of query-core. +type PersistableQuery = { + queryKey: readonly unknown[]; + state: { status: string }; +}; + +// How long a persisted entry stays restorable. Must be <= the queries' gcTime, +// or PersistQueryClientProvider drops the entry on restore. 24h survives normal +// restart cadence while letting very stale canvas data eventually expire. +export const CANVAS_PERSIST_MAX_AGE = 1000 * 60 * 60 * 24; + +// Bumped only when the persisted shape changes; a mismatch discards the whole +// on-disk blob on restore. NOT the auth identity: the auth store is anonymous at +// mount (it resolves async), so an identity buster would never match on a cold +// reload and restore would never fire. Cross-project isolation is handled by +// wiping the blob on logout/project-switch instead (see removePersistedCache). +export const CANVAS_PERSIST_BUSTER = "canvas-v1"; + +// Storage key for the on-disk query cache (one blob, shared by both hosts). +export const CANVAS_PERSIST_KEY = "posthog-code:rq-canvas-cache"; + +/** + * Persist ONLY the canvas surface's queries, matched by query key, and only once + * they've succeeded. An explicit allowlist keeps sessions, auth, current-user, + * and agent chat off disk, so the blob stays small and carries no secrets. + */ +export function shouldPersistCanvasQuery(query: PersistableQuery): boolean { + if (query.state.status !== "success") return false; + const key = query.queryKey; + if (!Array.isArray(key)) return false; + // useChannels uses a plain key: ["canvas-channels"]. + if (key[0] === "canvas-channels") return true; + // tRPC keys are shaped [[router, procedure], { input, type }]. + const path = key[0]; + if (Array.isArray(path)) { + const [routerName, procedure] = path as string[]; + if (routerName === "dashboards") { + return procedure === "list" || procedure === "get"; + } + if (routerName === "channelTasks") return procedure === "list"; + } + return false; +} + +// The active persister, registered when a host builds its persist options. Lets +// removePersistedCache() wipe the on-disk blob without the auth layer needing to +// know which host's persister is in play. +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 { + await activePersister?.removeClient(); +} + +/** + * Build the PersistQueryClientProvider options for a host's persister, and + * register it so removePersistedCache() can reach it. Both hosts share the same + * predicate, maxAge, and buster. + */ +export function buildCanvasPersistOptions( + persister: Persister, +): Omit { + activePersister = persister; + return { + persister, + maxAge: CANVAS_PERSIST_MAX_AGE, + buster: CANVAS_PERSIST_BUSTER, + dehydrateOptions: { shouldDehydrateQuery: shouldPersistCanvasQuery }, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8bb9dab47..c59e25fdc3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,9 @@ catalogs: '@tanstack/react-query': specifier: ^5.90.2 version: 5.90.20 + '@tanstack/react-query-persist-client': + specifier: ^5.90.2 + version: 5.101.0 '@tanstack/react-router': specifier: ^1.95.0 version: 1.170.15 @@ -212,9 +215,15 @@ importers: '@tailwindcss/vite': specifier: ^4.2.2 version: 4.2.2(vite@6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@tanstack/query-async-storage-persister': + specifier: ^5.90.2 + version: 5.101.0 '@tanstack/react-query': specifier: ^5.90.2 version: 5.90.20(react@19.1.0) + '@tanstack/react-query-persist-client': + specifier: ^5.90.2 + version: 5.101.0(@tanstack/react-query@5.90.20(react@19.1.0))(react@19.1.0) '@tanstack/router-plugin': specifier: ^1.95.0 version: 1.168.18(@tanstack/react-router@1.170.15(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2)) @@ -664,9 +673,15 @@ importers: '@posthog/workspace-client': specifier: workspace:* version: link:../../packages/workspace-client + '@tanstack/query-sync-storage-persister': + specifier: ^5.90.2 + version: 5.101.0 '@tanstack/react-query': specifier: ^5.90.2 version: 5.90.20(react@19.1.0) + '@tanstack/react-query-persist-client': + specifier: ^5.90.2 + version: 5.101.0(@tanstack/react-query@5.90.20(react@19.1.0))(react@19.1.0) '@trpc/client': specifier: ^11.12.0 version: 11.12.0(@trpc/server@11.12.0(typescript@5.9.3))(typescript@5.9.3) @@ -1365,6 +1380,9 @@ importers: '@tanstack/react-query': specifier: 'catalog:' version: 5.90.20(react@19.1.0) + '@tanstack/react-query-persist-client': + specifier: 'catalog:' + version: 5.101.0(@tanstack/react-query@5.90.20(react@19.1.0))(react@19.1.0) '@tanstack/router-generator': specifier: 'catalog:' version: 1.167.17 @@ -6016,9 +6034,21 @@ packages: resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==} engines: {node: '>=20.19'} + '@tanstack/query-async-storage-persister@5.101.0': + resolution: {integrity: sha512-8Y8pwMVPh4kqU9m+MhbDYBuub+i5T2M9P/0+Ae3KccX1BwMIp6AdFAx3zDgLoZcEVI830mbJYf0sjAgsawCWVw==} + + '@tanstack/query-core@5.101.0': + resolution: {integrity: sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==} + '@tanstack/query-core@5.90.20': resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} + '@tanstack/query-persist-client-core@5.101.0': + resolution: {integrity: sha512-LH99WepGVLwlLfuOcQcPK7f3Xg/Gf+xlMMIj9xWu/8oQ3egnDzjr+a4HvEmi6PGob5SmGXvmDKZaH5+In9dzjw==} + + '@tanstack/query-sync-storage-persister@5.101.0': + resolution: {integrity: sha512-UAGbsIJe9vkV/rbFxUBOPH27Eu6K17KuVLfK1mSy8qoSUJ4qt6JKGMxA8NMUoowbPG0ZnTfRt1Y5SFsqfqlfcA==} + '@tanstack/react-devtools@0.10.5': resolution: {integrity: sha512-orVsRJ7oAXFb7oyafQCgx9YuK44jpILh5T/ddYuxAsolNfN5DZBr5/NLrWErD7HCGIzvYzg1TZI4sPxmiKvtvA==} engines: {node: '>=18'} @@ -6028,6 +6058,12 @@ packages: react: '>=16.8' react-dom: '>=16.8' + '@tanstack/react-query-persist-client@5.101.0': + resolution: {integrity: sha512-AUcdBgz8V6sM9axzdqkVmWjYSOETkhr6yAZSBnEFyZT2jo6vkFq3UrpRuxGs6fmhKMWv8FA+ZJGcbaKPaoAElQ==} + peerDependencies: + '@tanstack/react-query': ^5.101.0 + react: ^18 || ^19 + '@tanstack/react-query@5.90.20': resolution: {integrity: sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==} peerDependencies: @@ -18742,8 +18778,24 @@ snapshots: '@tanstack/history@1.162.0': {} + '@tanstack/query-async-storage-persister@5.101.0': + dependencies: + '@tanstack/query-core': 5.101.0 + '@tanstack/query-persist-client-core': 5.101.0 + + '@tanstack/query-core@5.101.0': {} + '@tanstack/query-core@5.90.20': {} + '@tanstack/query-persist-client-core@5.101.0': + dependencies: + '@tanstack/query-core': 5.101.0 + + '@tanstack/query-sync-storage-persister@5.101.0': + dependencies: + '@tanstack/query-core': 5.101.0 + '@tanstack/query-persist-client-core': 5.101.0 + '@tanstack/react-devtools@0.10.5(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(csstype@3.2.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(solid-js@1.9.13)': dependencies: '@tanstack/devtools': 0.12.2(csstype@3.2.3)(solid-js@1.9.13) @@ -18757,6 +18809,12 @@ snapshots: - solid-js - utf-8-validate + '@tanstack/react-query-persist-client@5.101.0(@tanstack/react-query@5.90.20(react@19.1.0))(react@19.1.0)': + dependencies: + '@tanstack/query-persist-client-core': 5.101.0 + '@tanstack/react-query': 5.90.20(react@19.1.0) + react: 19.1.0 + '@tanstack/react-query@5.90.20(react@19.1.0)': dependencies: '@tanstack/query-core': 5.90.20 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 409b55ce1f..84380c288f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,8 +11,11 @@ catalog: '@posthog/quill': 0.3.0-beta.19 '@radix-ui/themes': ^3.2.1 '@tanstack/devtools-vite': ^0.7.0 + '@tanstack/query-async-storage-persister': ^5.90.2 + '@tanstack/query-sync-storage-persister': ^5.90.2 '@tanstack/react-devtools': ^0.10.5 '@tanstack/react-query': ^5.90.2 + '@tanstack/react-query-persist-client': ^5.90.2 '@tanstack/react-router': ^1.95.0 '@tanstack/react-router-devtools': ^1.95.0 '@tanstack/router-generator': ^1.95.0 From 97708136def963e2b1e8c1122b65ad2eddbd72ff Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Sat, 27 Jun 2026 16:13:23 -0700 Subject: [PATCH 2/3] Address review: single query-core, no cold-start flash, parameterised 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 --- apps/code/package.json | 4 +- apps/web/package.json | 4 +- package.json | 5 + .../src/canvas/channelTasksService.test.ts | 32 ++- .../core/src/canvas/dashboardsService.test.ts | 31 ++- .../components/WebsiteDashboardsIndex.tsx | 14 +- .../ui/src/shell/queryPersistence.test.ts | 89 +++---- packages/ui/src/shell/queryPersistence.ts | 4 +- pnpm-lock.yaml | 250 ++++++++---------- pnpm-workspace.yaml | 10 +- 10 files changed, 219 insertions(+), 224 deletions(-) diff --git a/apps/code/package.json b/apps/code/package.json index ec45f80cb7..f313349a2a 100644 --- a/apps/code/package.json +++ b/apps/code/package.json @@ -118,9 +118,9 @@ "@posthog/workspace-server": "workspace:*", "@radix-ui/themes": "^3.2.1", "@tailwindcss/vite": "^4.2.2", - "@tanstack/query-async-storage-persister": "^5.90.2", + "@tanstack/query-async-storage-persister": "5.90.20", "@tanstack/react-query": "^5.90.2", - "@tanstack/react-query-persist-client": "^5.90.2", + "@tanstack/react-query-persist-client": "5.90.20", "@tanstack/router-plugin": "^1.95.0", "@trpc/client": "^11.12.0", "@trpc/server": "^11.12.0", diff --git a/apps/web/package.json b/apps/web/package.json index 14b249c0eb..b50ec73587 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -18,9 +18,9 @@ "@posthog/shared": "workspace:*", "@posthog/ui": "workspace:*", "@posthog/workspace-client": "workspace:*", - "@tanstack/query-sync-storage-persister": "^5.90.2", + "@tanstack/query-sync-storage-persister": "5.90.20", "@tanstack/react-query": "^5.90.2", - "@tanstack/react-query-persist-client": "^5.90.2", + "@tanstack/react-query-persist-client": "5.90.20", "@trpc/client": "^11.12.0", "@trpc/tanstack-react-query": "^11.12.0", "inversify": "^7.10.6", diff --git a/package.json b/package.json index 3cc027e2f1..fffd7ac80e 100644 --- a/package.json +++ b/package.json @@ -60,5 +60,10 @@ "biome check --write --unsafe --files-ignore-unknown=true --no-errors-on-unmatched", "bash -c 'pnpm typecheck'" ] + }, + "pnpm": { + "overrides": { + "@tanstack/query-core": "5.90.20" + } } } diff --git a/packages/core/src/canvas/channelTasksService.test.ts b/packages/core/src/canvas/channelTasksService.test.ts index c7eef34bd6..e48184482e 100644 --- a/packages/core/src/canvas/channelTasksService.test.ts +++ b/packages/core/src/canvas/channelTasksService.test.ts @@ -31,28 +31,32 @@ function fakeFs(rows: FsEntryBase[]) { } describe("ChannelTasksService.list", () => { - it("uses a known channelPath without resolving it via getEntry", async () => { + it.each([ + { + name: "skips getEntry when a channelPath is supplied", + channelPath: "marketing/team" as string | undefined, + getEntryCalls: 0, + }, + { + name: "resolves the path via getEntry when none is supplied", + channelPath: undefined as string | undefined, + getEntryCalls: 1, + }, + ])("$name", async ({ channelPath, getEntryCalls }) => { const { fs, fetch, getEntry } = fakeFs([]); const service = new ChannelTasksService(fs); - await service.list("chan-1", "marketing/team"); + await service.list("chan-1", channelPath); - // The path was supplied, so no getEntry round-trip; the list GET uses it. - expect(getEntry).not.toHaveBeenCalled(); + expect(getEntry).toHaveBeenCalledTimes(getEntryCalls); + // The list GET filters by task type under the resolved/supplied path. const [suffix] = fetch.mock.calls[0]; - expect(suffix).toContain(encodeURIComponent("marketing/team")); + expect(suffix).toContain( + encodeURIComponent(channelPath ?? "Channels/chan-1"), + ); expect(suffix).toContain("type=task"); }); - it("falls back to resolving the path via getEntry when none is given", async () => { - const { fs, getEntry } = fakeFs([]); - const service = new ChannelTasksService(fs); - - await service.list("chan-1"); - - expect(getEntry).toHaveBeenCalledTimes(1); - }); - it("maps rows to records sorted by createdAt descending, dropping ref-less rows", async () => { const { fs } = fakeFs([ taskRow("a", "task-a", "2026-01-01T00:00:00Z"), diff --git a/packages/core/src/canvas/dashboardsService.test.ts b/packages/core/src/canvas/dashboardsService.test.ts index cac8006f5c..38a1635571 100644 --- a/packages/core/src/canvas/dashboardsService.test.ts +++ b/packages/core/src/canvas/dashboardsService.test.ts @@ -55,25 +55,28 @@ describe("DashboardsService.list", () => { expect(query).toContain("type=dashboard"); }); - it("uses a known channelPath without resolving it via getEntry", async () => { + it.each([ + { + name: "skips getEntry when a channelPath is supplied", + channelPath: "marketing/team" as string | undefined, + getEntryCalls: 0, + }, + { + name: "resolves the path via getEntry when none is supplied", + channelPath: undefined as string | undefined, + getEntryCalls: 1, + }, + ])("$name", async ({ channelPath, getEntryCalls }) => { const { fs, listByQuery, getEntry } = fakeFs([]); const service = new DashboardsService(fs, {} as never); - await service.list("chan-1", "marketing/team"); + await service.list("chan-1", channelPath); - // The path was supplied, so no getEntry round-trip; the list query uses it. - expect(getEntry).not.toHaveBeenCalled(); + expect(getEntry).toHaveBeenCalledTimes(getEntryCalls); const [query] = listByQuery.mock.calls[0]; - expect(query).toContain(encodeURIComponent("marketing/team")); - }); - - it("falls back to resolving the path via getEntry when none is given", async () => { - const { fs, getEntry } = fakeFs([]); - const service = new DashboardsService(fs, {} as never); - - await service.list("chan-1"); - - expect(getEntry).toHaveBeenCalledTimes(1); + expect(query).toContain( + encodeURIComponent(channelPath ?? "Channels/chan-1"), + ); }); it("maps rows to summaries sorted by updatedAt descending", async () => { diff --git a/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx b/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx index ff5d74262b..8044ab4110 100644 --- a/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx @@ -50,17 +50,23 @@ const PREVIEW_VIEWPORT = { once: false, rootMargin: "400px 0px" } as const; export function WebsiteDashboardsIndex({ channelId }: { channelId: string }) { // Resolve the channel's path from the (already-loaded) channels list so the // list query shares its cache key with the sidebar's and the service can skip - // the getEntry path-resolve round-trip. - const { channels } = useChannels(); + // the getEntry path-resolve round-trip. Hold the dashboards query until + // channels have loaded so it fires once with the resolved path — otherwise a + // cold start fetches first with `channelPath: undefined`, then re-fetches + // under a new key once the path lands, flashing the grid back to loading. + const { channels, isLoading: channelsLoading } = useChannels(); const channelPath = channels.find((c) => c.id === channelId)?.path; - const { dashboards, isLoading } = useDashboards(channelId, channelPath); + const { dashboards, isLoading } = useDashboards( + channelsLoading ? undefined : channelId, + channelPath, + ); // templateId -> display name, for the per-card badge ("Freeform (React)", …). // Falls back to the raw id for any template not in the registry. const templates = useCanvasTemplates(); const templateLabels = new Map(templates.map((t) => [t.id, t.name])); - if (isLoading) return null; + if (channelsLoading || isLoading) return null; if (dashboards.length === 0) { return ( diff --git a/packages/ui/src/shell/queryPersistence.test.ts b/packages/ui/src/shell/queryPersistence.test.ts index 6fae34cad6..b669f9d5d7 100644 --- a/packages/ui/src/shell/queryPersistence.test.ts +++ b/packages/ui/src/shell/queryPersistence.test.ts @@ -10,53 +10,48 @@ function fakeQuery(queryKey: unknown, status = "success"): PredicateArg { } describe("shouldPersistCanvasQuery", () => { - it("persists the channels list", () => { - expect(shouldPersistCanvasQuery(fakeQuery(["canvas-channels"]))).toBe(true); + it.each([ + { name: "channels list", key: ["canvas-channels"], expected: true }, + { + name: "dashboards.list", + key: [["dashboards", "list"], { input: {}, type: "query" }], + expected: true, + }, + { + name: "dashboards.get", + key: [["dashboards", "get"], { input: {}, type: "query" }], + expected: true, + }, + { + name: "channelTasks.list", + key: [["channelTasks", "list"], { input: {}, type: "query" }], + expected: true, + }, + { + name: "auth current-user", + key: ["auth", "current-user", "us:2"], + expected: false, + }, + { + name: "sessions.list", + key: [["sessions", "list"], { input: {}, type: "query" }], + expected: false, + }, + { + name: "dashboards.saveFreeform (mutation-shaped)", + key: [["dashboards", "saveFreeform"], { input: {} }], + expected: false, + }, + ])("$name → $expected", ({ key, expected }) => { + expect(shouldPersistCanvasQuery(fakeQuery(key))).toBe(expected); }); - 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); - }); + it.each(["pending", "error"])( + "does not persist a canvas query in %s state", + (status) => { + expect( + shouldPersistCanvasQuery(fakeQuery(["canvas-channels"], status)), + ).toBe(false); + }, + ); }); diff --git a/packages/ui/src/shell/queryPersistence.ts b/packages/ui/src/shell/queryPersistence.ts index 727e468659..ede2af1bc8 100644 --- a/packages/ui/src/shell/queryPersistence.ts +++ b/packages/ui/src/shell/queryPersistence.ts @@ -4,8 +4,8 @@ import type { } from "@tanstack/react-query-persist-client"; // Only the fields the predicate reads. Declared structurally (rather than -// importing `Query`) so it stays assignable to `shouldDehydrateQuery` even when -// react-query and the persist client resolve different copies of query-core. +// importing `Query`) so the predicate stays assignable to `shouldDehydrateQuery` +// without coupling to query-core's exact `Query` type. type PersistableQuery = { queryKey: readonly unknown[]; state: { status: string }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c59e25fdc3..52266609ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,8 +31,8 @@ catalogs: specifier: ^5.90.2 version: 5.90.20 '@tanstack/react-query-persist-client': - specifier: ^5.90.2 - version: 5.101.0 + specifier: 5.90.20 + version: 5.90.20 '@tanstack/react-router': specifier: ^1.95.0 version: 1.170.15 @@ -86,8 +86,7 @@ catalogs: version: 5.9.3 overrides: - zod: 4.3.6 - '@posthog/quill>@base-ui/react': ^1.3.0 + '@tanstack/query-core': 5.90.20 patchedDependencies: node-pty: @@ -133,10 +132,10 @@ importers: version: 2.2.0(inversify@7.11.0(reflect-metadata@0.2.2)) '@json-render/core': specifier: ^0.19.0 - version: 0.19.0(zod@4.3.6) + version: 0.19.0(zod@4.4.3) '@modelcontextprotocol/sdk': specifier: ^1.12.1 - version: 1.27.1(zod@4.3.6) + version: 1.27.1(zod@4.4.3) '@opentelemetry/api-logs': specifier: ^0.208.0 version: 0.208.0 @@ -216,14 +215,14 @@ importers: specifier: ^4.2.2 version: 4.2.2(vite@6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@tanstack/query-async-storage-persister': - specifier: ^5.90.2 - version: 5.101.0 + specifier: 5.90.20 + version: 5.90.20 '@tanstack/react-query': specifier: ^5.90.2 version: 5.90.20(react@19.1.0) '@tanstack/react-query-persist-client': - specifier: ^5.90.2 - version: 5.101.0(@tanstack/react-query@5.90.20(react@19.1.0))(react@19.1.0) + specifier: 5.90.20 + version: 5.90.20(@tanstack/react-query@5.90.20(react@19.1.0))(react@19.1.0) '@tanstack/router-plugin': specifier: ^1.95.0 version: 1.168.18(@tanstack/react-router@1.170.15(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2)) @@ -321,8 +320,8 @@ importers: specifier: ^1.4.0 version: 1.4.0 zod: - specifier: 4.3.6 - version: 4.3.6 + specifier: ^4.1.12 + version: 4.4.3 devDependencies: '@biomejs/biome': specifier: 2.2.4 @@ -674,14 +673,14 @@ importers: specifier: workspace:* version: link:../../packages/workspace-client '@tanstack/query-sync-storage-persister': - specifier: ^5.90.2 - version: 5.101.0 + specifier: 5.90.20 + version: 5.90.20 '@tanstack/react-query': specifier: ^5.90.2 version: 5.90.20(react@19.1.0) '@tanstack/react-query-persist-client': - specifier: ^5.90.2 - version: 5.101.0(@tanstack/react-query@5.90.20(react@19.1.0))(react@19.1.0) + specifier: 5.90.20 + version: 5.90.20(@tanstack/react-query@5.90.20(react@19.1.0))(react@19.1.0) '@trpc/client': specifier: ^11.12.0 version: 11.12.0(@trpc/server@11.12.0(typescript@5.9.3))(typescript@5.9.3) @@ -727,19 +726,19 @@ importers: dependencies: '@agentclientprotocol/sdk': specifier: 0.25.0 - version: 0.25.0(zod@4.3.6) + version: 0.25.0(zod@4.4.3) '@anthropic-ai/claude-agent-sdk': specifier: 0.3.170 - version: 0.3.170(@anthropic-ai/sdk@0.104.1(zod@4.3.6))(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(zod@4.3.6) + version: 0.3.170(@anthropic-ai/sdk@0.104.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) '@anthropic-ai/sdk': specifier: 0.104.1 - version: 0.104.1(zod@4.3.6) + version: 0.104.1(zod@4.4.3) '@hono/node-server': specifier: ^1.19.9 version: 1.19.9(hono@4.11.7) '@modelcontextprotocol/sdk': specifier: 1.29.0 - version: 1.29.0(zod@4.3.6) + version: 1.29.0(zod@4.4.3) '@opentelemetry/api-logs': specifier: ^0.208.0 version: 0.208.0 @@ -780,8 +779,8 @@ importers: specifier: ^0.3.3 version: 0.3.3 zod: - specifier: 4.3.6 - version: 4.3.6 + specifier: ^4.2.0 + version: 4.4.3 devDependencies: '@posthog/enricher': specifier: workspace:* @@ -837,13 +836,13 @@ importers: dependencies: '@json-render/core': specifier: ^0.19.0 - version: 0.19.0(zod@4.3.6) + version: 0.19.0(zod@4.4.3) '@modelcontextprotocol/ext-apps': specifier: ^1.1.2 - version: 1.2.2(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(zod@4.3.6) + version: 1.2.2(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(zod@4.4.3) '@modelcontextprotocol/sdk': specifier: ^1.12.1 - version: 1.29.0(zod@4.3.6) + version: 1.29.0(zod@4.4.3) '@pierre/diffs': specifier: ^1.2.10 version: 1.2.10(@shikijs/themes@3.23.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -872,8 +871,8 @@ importers: specifier: 'catalog:' version: 0.2.2 zod: - specifier: 4.3.6 - version: 4.3.6 + specifier: ^4.1.12 + version: 4.4.3 zustand: specifier: ^4.5.0 version: 4.5.7(@types/react@19.2.11)(immer@11.1.3)(react@19.1.0) @@ -943,8 +942,8 @@ importers: specifier: ^2.1.8 version: 2.1.9(@types/node@24.12.0)(jsdom@26.1.0)(lightningcss@1.32.0)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(terser@5.46.0) zod: - specifier: 4.3.6 - version: 4.3.6 + specifier: ^4.2.0 + version: 4.4.3 packages/enricher: dependencies: @@ -1070,12 +1069,12 @@ importers: packages/shared: dependencies: zod: - specifier: 4.3.6 - version: 4.3.6 + specifier: ^4.1.12 + version: 4.4.3 devDependencies: '@agentclientprotocol/sdk': specifier: 0.19.0 - version: 0.19.0(zod@4.3.6) + version: 0.19.0(zod@4.4.3) tsup: specifier: ^8.5.1 version: 8.5.1(jiti@2.7.0)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) @@ -1382,7 +1381,7 @@ importers: version: 5.90.20(react@19.1.0) '@tanstack/react-query-persist-client': specifier: 'catalog:' - version: 5.101.0(@tanstack/react-query@5.90.20(react@19.1.0))(react@19.1.0) + version: 5.90.20(@tanstack/react-query@5.90.20(react@19.1.0))(react@19.1.0) '@tanstack/router-generator': specifier: 'catalog:' version: 1.167.17 @@ -1464,10 +1463,10 @@ importers: dependencies: '@agentclientprotocol/sdk': specifier: 0.22.1 - version: 0.22.1(zod@4.3.6) + version: 0.22.1(zod@4.4.3) '@anthropic-ai/claude-agent-sdk': specifier: 0.3.156 - version: 0.3.156(@anthropic-ai/sdk@0.104.1(zod@4.3.6))(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(zod@4.3.6) + version: 0.3.156(@anthropic-ai/sdk@0.104.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) '@hono/node-server': specifier: 'catalog:' version: 1.19.9(hono@4.11.7) @@ -1529,8 +1528,8 @@ importers: specifier: 'catalog:' version: 2.2.6 zod: - specifier: 4.3.6 - version: 4.3.6 + specifier: ^4.1.12 + version: 4.4.3 devDependencies: '@inversifyjs/strongly-typed': specifier: 2.2.0 @@ -1578,17 +1577,17 @@ packages: '@agentclientprotocol/sdk@0.19.0': resolution: {integrity: sha512-U9I8ws9WTOk6jCBAWpXefGSDgVXn14/kV6HFzwWGcstQ02mOQgClMAROHmoIn9GqZbDBDEOkdIbP4P4TEMQdug==} peerDependencies: - zod: 4.3.6 + zod: ^3.25.0 || ^4.0.0 '@agentclientprotocol/sdk@0.22.1': resolution: {integrity: sha512-DfqXtl/8gO9NImq094MTaCXEU2vkhh6v7q/kT+9UjZxUqj8hYaya2OjLVIqn16MzNHcXEpShTR2RIauLSYeDQQ==} peerDependencies: - zod: 4.3.6 + zod: ^3.25.0 || ^4.0.0 '@agentclientprotocol/sdk@0.25.0': resolution: {integrity: sha512-wU1VgXNtMvdVotX49txc3WJUDV+/QbLpsgjMvFhlRmp37osdLbI7L7y+iwAlQATwfjLxcv1r1p3ZxZBcXlGhcQ==} peerDependencies: - zod: 4.3.6 + zod: ^3.25.0 || ^4.0.0 '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} @@ -1692,7 +1691,7 @@ packages: peerDependencies: '@anthropic-ai/sdk': '>=0.93.0' '@modelcontextprotocol/sdk': ^1.29.0 - zod: 4.3.6 + zod: ^4.0.0 '@anthropic-ai/claude-agent-sdk@0.3.170': resolution: {integrity: sha512-pAvhfk+iTodXZ6RF18Kz7BEUWFjL7EcR3tKuhUNdPpE1NAYCR3mSHGbafi72JsrNwKEDIs7FU31z3fqhwy8QzA==} @@ -1700,13 +1699,13 @@ packages: peerDependencies: '@anthropic-ai/sdk': '>=0.93.0' '@modelcontextprotocol/sdk': ^1.29.0 - zod: 4.3.6 + zod: ^4.0.0 '@anthropic-ai/sdk@0.104.1': resolution: {integrity: sha512-gGACa/+IaiXzRRmF96aOhamoBgapKRBiFWbmmTFP8aMkpaEcuStF+Q61bjo4vPxBM7gqWJNZqsngslRdnLHv0Q==} hasBin: true peerDependencies: - zod: 4.3.6 + zod: ^3.25.0 || ^4.0.0 peerDependenciesMeta: zod: optional: true @@ -3747,7 +3746,7 @@ packages: '@json-render/core@0.19.0': resolution: {integrity: sha512-vvcyZ+10EDZKbEyB1J2kXOGfDaiZR2LurZGSqi2r5STHyKr+Te85DWaBxTwRGgM7U1LtIvNx85BzzjElRKoAIg==} peerDependencies: - zod: 4.3.6 + zod: ^4.0.0 '@json-render/react@0.19.0': resolution: {integrity: sha512-kTW6b6cSNRrlEfCUf/69SLoLn+CufC968ruge9tnQlp9pDTGG/SK8pgM541FdgwMFA4zm3s5mpM3G8rdODKc/A==} @@ -3964,7 +3963,7 @@ packages: '@modelcontextprotocol/sdk': ^1.24.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 - zod: 4.3.6 + zod: ^3.25.0 || ^4.0.0 peerDependenciesMeta: react: optional: true @@ -3976,7 +3975,7 @@ packages: engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 - zod: 4.3.6 + zod: ^3.25 || ^4.0 peerDependenciesMeta: '@cfworker/json-schema': optional: true @@ -3986,7 +3985,7 @@ packages: engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 - zod: 4.3.6 + zod: ^3.25 || ^4.0 peerDependenciesMeta: '@cfworker/json-schema': optional: true @@ -4596,7 +4595,7 @@ packages: resolution: {integrity: sha512-KMk/TQeuyYpC0GpNTViVkSqGVIniCEeyEravDoKn6LDbLw0jYdXI9kpovvy70KLjMlUilKmkg9jbJbebPKShjA==} engines: {node: '>=20'} peerDependencies: - '@base-ui/react': ^1.3.0 + '@base-ui/react': ^1.4.0 react: ^18.3.1 || ^19.0.0 react-dom: ^18.3.1 || ^19.0.0 tailwindcss: ^4.0.0 @@ -6034,20 +6033,17 @@ packages: resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==} engines: {node: '>=20.19'} - '@tanstack/query-async-storage-persister@5.101.0': - resolution: {integrity: sha512-8Y8pwMVPh4kqU9m+MhbDYBuub+i5T2M9P/0+Ae3KccX1BwMIp6AdFAx3zDgLoZcEVI830mbJYf0sjAgsawCWVw==} - - '@tanstack/query-core@5.101.0': - resolution: {integrity: sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==} + '@tanstack/query-async-storage-persister@5.90.20': + resolution: {integrity: sha512-SO7U/v/NcWTL4aJTyZmdW6i6KmN/YlEmNTEiU4Fm8cwGDTBQ2ABpaHY6A+Q/gsDdG7AeDwFvVs/wf9C4A0UKAw==} '@tanstack/query-core@5.90.20': resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} - '@tanstack/query-persist-client-core@5.101.0': - resolution: {integrity: sha512-LH99WepGVLwlLfuOcQcPK7f3Xg/Gf+xlMMIj9xWu/8oQ3egnDzjr+a4HvEmi6PGob5SmGXvmDKZaH5+In9dzjw==} + '@tanstack/query-persist-client-core@5.91.17': + resolution: {integrity: sha512-NfXUCUzar8Y3fw0F6lPlrnJWfg148IXvWBIr/0x2LbYAoGdAnJDU3PzVaLTafI2vKQqIphe3uAq1068+nhn+nQ==} - '@tanstack/query-sync-storage-persister@5.101.0': - resolution: {integrity: sha512-UAGbsIJe9vkV/rbFxUBOPH27Eu6K17KuVLfK1mSy8qoSUJ4qt6JKGMxA8NMUoowbPG0ZnTfRt1Y5SFsqfqlfcA==} + '@tanstack/query-sync-storage-persister@5.90.20': + resolution: {integrity: sha512-VGVJsYLzOUxDzYOEiB1HNd7cxWbU9eEH+oEiVumIbBwWEcjvW5ClFHQgMAUPUe3vXowXcPz7NulVrIU46h6rbg==} '@tanstack/react-devtools@0.10.5': resolution: {integrity: sha512-orVsRJ7oAXFb7oyafQCgx9YuK44jpILh5T/ddYuxAsolNfN5DZBr5/NLrWErD7HCGIzvYzg1TZI4sPxmiKvtvA==} @@ -6058,10 +6054,10 @@ packages: react: '>=16.8' react-dom: '>=16.8' - '@tanstack/react-query-persist-client@5.101.0': - resolution: {integrity: sha512-AUcdBgz8V6sM9axzdqkVmWjYSOETkhr6yAZSBnEFyZT2jo6vkFq3UrpRuxGs6fmhKMWv8FA+ZJGcbaKPaoAElQ==} + '@tanstack/react-query-persist-client@5.90.20': + resolution: {integrity: sha512-FiKLxu7haxUAVV9hUEaUkUF6CsOM8i0ERtE6ETLQ0t5aKefY9tRByP9J1C4qf1r1m2TSWV0WwkmOsmsy1pZqsQ==} peerDependencies: - '@tanstack/react-query': ^5.101.0 + '@tanstack/react-query': ^5.90.18 react: ^18 || ^19 '@tanstack/react-query@5.90.20': @@ -13563,10 +13559,10 @@ packages: zod-to-json-schema@3.25.1: resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} peerDependencies: - zod: 4.3.6 + zod: ^3.25 || ^4 - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -13602,21 +13598,17 @@ snapshots: '@adobe/css-tools@4.4.4': {} - '@agentclientprotocol/sdk@0.19.0(zod@4.3.6)': - dependencies: - zod: 4.3.6 - - '@agentclientprotocol/sdk@0.22.1(zod@4.3.6)': + '@agentclientprotocol/sdk@0.19.0(zod@4.4.3)': dependencies: - zod: 4.3.6 + zod: 4.4.3 '@agentclientprotocol/sdk@0.22.1(zod@4.4.3)': dependencies: zod: 4.4.3 - '@agentclientprotocol/sdk@0.25.0(zod@4.3.6)': + '@agentclientprotocol/sdk@0.25.0(zod@4.4.3)': dependencies: - zod: 4.3.6 + zod: 4.4.3 '@alloc/quick-lru@5.2.0': {} @@ -13673,11 +13665,11 @@ snapshots: '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.170': optional: true - '@anthropic-ai/claude-agent-sdk@0.3.156(@anthropic-ai/sdk@0.104.1(zod@4.3.6))(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(zod@4.3.6)': + '@anthropic-ai/claude-agent-sdk@0.3.156(@anthropic-ai/sdk@0.104.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': dependencies: - '@anthropic-ai/sdk': 0.104.1(zod@4.3.6) - '@modelcontextprotocol/sdk': 1.29.0(zod@4.3.6) - zod: 4.3.6 + '@anthropic-ai/sdk': 0.104.1(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + zod: 4.4.3 optionalDependencies: '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.156 '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.156 @@ -13688,11 +13680,11 @@ snapshots: '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.156 '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.156 - '@anthropic-ai/claude-agent-sdk@0.3.170(@anthropic-ai/sdk@0.104.1(zod@4.3.6))(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(zod@4.3.6)': + '@anthropic-ai/claude-agent-sdk@0.3.170(@anthropic-ai/sdk@0.104.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': dependencies: - '@anthropic-ai/sdk': 0.104.1(zod@4.3.6) - '@modelcontextprotocol/sdk': 1.29.0(zod@4.3.6) - zod: 4.3.6 + '@anthropic-ai/sdk': 0.104.1(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + zod: 4.4.3 optionalDependencies: '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.170 '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.170 @@ -13703,12 +13695,12 @@ snapshots: '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.170 '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.170 - '@anthropic-ai/sdk@0.104.1(zod@4.3.6)': + '@anthropic-ai/sdk@0.104.1(zod@4.4.3)': dependencies: json-schema-to-ts: 3.1.1 standardwebhooks: 1.0.0 optionalDependencies: - zod: 4.3.6 + zod: 4.4.3 '@apidevtools/json-schema-ref-parser@11.7.2': dependencies: @@ -16224,7 +16216,7 @@ snapshots: dependencies: '@jimp/types': 1.6.0 '@jimp/utils': 1.6.0 - zod: 4.3.6 + zod: 3.25.76 '@jimp/plugin-blur@1.6.0': dependencies: @@ -16234,7 +16226,7 @@ snapshots: '@jimp/plugin-circle@1.6.0': dependencies: '@jimp/types': 1.6.0 - zod: 4.3.6 + zod: 3.25.76 '@jimp/plugin-color@1.6.0': dependencies: @@ -16242,7 +16234,7 @@ snapshots: '@jimp/types': 1.6.0 '@jimp/utils': 1.6.0 tinycolor2: 1.6.0 - zod: 4.3.6 + zod: 3.25.76 '@jimp/plugin-contain@1.6.0': dependencies: @@ -16251,7 +16243,7 @@ snapshots: '@jimp/plugin-resize': 1.6.0 '@jimp/types': 1.6.0 '@jimp/utils': 1.6.0 - zod: 4.3.6 + zod: 3.25.76 '@jimp/plugin-cover@1.6.0': dependencies: @@ -16259,20 +16251,20 @@ snapshots: '@jimp/plugin-crop': 1.6.0 '@jimp/plugin-resize': 1.6.0 '@jimp/types': 1.6.0 - zod: 4.3.6 + zod: 3.25.76 '@jimp/plugin-crop@1.6.0': dependencies: '@jimp/core': 1.6.0 '@jimp/types': 1.6.0 '@jimp/utils': 1.6.0 - zod: 4.3.6 + zod: 3.25.76 '@jimp/plugin-displace@1.6.0': dependencies: '@jimp/types': 1.6.0 '@jimp/utils': 1.6.0 - zod: 4.3.6 + zod: 3.25.76 '@jimp/plugin-dither@1.6.0': dependencies: @@ -16282,12 +16274,12 @@ snapshots: dependencies: '@jimp/types': 1.6.0 '@jimp/utils': 1.6.0 - zod: 4.3.6 + zod: 3.25.76 '@jimp/plugin-flip@1.6.0': dependencies: '@jimp/types': 1.6.0 - zod: 4.3.6 + zod: 3.25.76 '@jimp/plugin-hash@1.6.0': dependencies: @@ -16305,7 +16297,7 @@ snapshots: '@jimp/plugin-mask@1.6.0': dependencies: '@jimp/types': 1.6.0 - zod: 4.3.6 + zod: 3.25.76 '@jimp/plugin-print@1.6.0': dependencies: @@ -16318,18 +16310,18 @@ snapshots: parse-bmfont-binary: 1.0.6 parse-bmfont-xml: 1.1.6 simple-xml-to-json: 1.2.3 - zod: 4.3.6 + zod: 3.25.76 '@jimp/plugin-quantize@1.6.0': dependencies: image-q: 4.0.0 - zod: 4.3.6 + zod: 3.25.76 '@jimp/plugin-resize@1.6.0': dependencies: '@jimp/core': 1.6.0 '@jimp/types': 1.6.0 - zod: 4.3.6 + zod: 3.25.76 '@jimp/plugin-rotate@1.6.0': dependencies: @@ -16338,7 +16330,7 @@ snapshots: '@jimp/plugin-resize': 1.6.0 '@jimp/types': 1.6.0 '@jimp/utils': 1.6.0 - zod: 4.3.6 + zod: 3.25.76 '@jimp/plugin-threshold@1.6.0': dependencies: @@ -16347,11 +16339,11 @@ snapshots: '@jimp/plugin-hash': 1.6.0 '@jimp/types': 1.6.0 '@jimp/utils': 1.6.0 - zod: 4.3.6 + zod: 3.25.76 '@jimp/types@1.6.0': dependencies: - zod: 4.3.6 + zod: 3.25.76 '@jimp/utils@1.6.0': dependencies: @@ -16394,10 +16386,6 @@ snapshots: '@jsdevtools/ono@7.1.3': {} - '@json-render/core@0.19.0(zod@4.3.6)': - dependencies: - zod: 4.3.6 - '@json-render/core@0.19.0(zod@4.4.3)': dependencies: zod: 4.4.3 @@ -16661,14 +16649,6 @@ snapshots: '@mixmark-io/domino@2.2.0': {} - '@modelcontextprotocol/ext-apps@1.2.2(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(zod@4.3.6)': - dependencies: - '@modelcontextprotocol/sdk': 1.29.0(zod@4.3.6) - zod: 4.3.6 - optionalDependencies: - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - '@modelcontextprotocol/ext-apps@1.2.2(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(zod@4.4.3)': dependencies: '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) @@ -16677,7 +16657,7 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - '@modelcontextprotocol/sdk@1.27.1(zod@4.3.6)': + '@modelcontextprotocol/sdk@1.27.1(zod@4.4.3)': dependencies: '@hono/node-server': 1.19.9(hono@4.11.7) ajv: 8.17.1 @@ -16694,12 +16674,12 @@ snapshots: json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 - zod: 4.3.6 - zod-to-json-schema: 3.25.1(zod@4.3.6) + zod: 4.4.3 + zod-to-json-schema: 3.25.1(zod@4.4.3) transitivePeerDependencies: - supports-color - '@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)': + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.9(hono@4.11.7) ajv: 8.17.1 @@ -16716,8 +16696,8 @@ snapshots: json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 - zod: 4.3.6 - zod-to-json-schema: 3.25.1(zod@4.3.6) + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) transitivePeerDependencies: - supports-color @@ -18778,23 +18758,21 @@ snapshots: '@tanstack/history@1.162.0': {} - '@tanstack/query-async-storage-persister@5.101.0': + '@tanstack/query-async-storage-persister@5.90.20': dependencies: - '@tanstack/query-core': 5.101.0 - '@tanstack/query-persist-client-core': 5.101.0 - - '@tanstack/query-core@5.101.0': {} + '@tanstack/query-core': 5.90.20 + '@tanstack/query-persist-client-core': 5.91.17 '@tanstack/query-core@5.90.20': {} - '@tanstack/query-persist-client-core@5.101.0': + '@tanstack/query-persist-client-core@5.91.17': dependencies: - '@tanstack/query-core': 5.101.0 + '@tanstack/query-core': 5.90.20 - '@tanstack/query-sync-storage-persister@5.101.0': + '@tanstack/query-sync-storage-persister@5.90.20': dependencies: - '@tanstack/query-core': 5.101.0 - '@tanstack/query-persist-client-core': 5.101.0 + '@tanstack/query-core': 5.90.20 + '@tanstack/query-persist-client-core': 5.91.17 '@tanstack/react-devtools@0.10.5(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(csstype@3.2.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(solid-js@1.9.13)': dependencies: @@ -18809,9 +18787,9 @@ snapshots: - solid-js - utf-8-validate - '@tanstack/react-query-persist-client@5.101.0(@tanstack/react-query@5.90.20(react@19.1.0))(react@19.1.0)': + '@tanstack/react-query-persist-client@5.90.20(@tanstack/react-query@5.90.20(react@19.1.0))(react@19.1.0)': dependencies: - '@tanstack/query-persist-client-core': 5.101.0 + '@tanstack/query-persist-client-core': 5.91.17 '@tanstack/react-query': 5.90.20(react@19.1.0) react: 19.1.0 @@ -18877,7 +18855,7 @@ snapshots: jiti: 2.7.0 magic-string: 0.30.21 prettier: 3.8.1 - zod: 4.3.6 + zod: 4.4.3 transitivePeerDependencies: - supports-color @@ -18891,7 +18869,7 @@ snapshots: '@tanstack/router-utils': 1.162.2 chokidar: 5.0.0 unplugin: 3.0.0 - zod: 4.3.6 + zod: 4.4.3 optionalDependencies: '@tanstack/react-router': 1.170.15(react-dom@19.1.0(react@19.1.0))(react@19.1.0) vite: 6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) @@ -22981,7 +22959,7 @@ snapshots: smol-toml: 1.6.0 strip-json-comments: 5.0.3 typescript: 5.9.3 - zod: 4.3.6 + zod: 4.4.3 lan-network@0.1.7: {} @@ -25918,7 +25896,7 @@ snapshots: '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) '@dotenvx/dotenvx': 1.60.1 - '@modelcontextprotocol/sdk': 1.29.0(zod@4.3.6) + '@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76) '@types/validate-npm-package-name': 4.0.2 browserslist: 4.28.1 commander: 14.0.3 @@ -25945,8 +25923,8 @@ snapshots: ts-morph: 26.0.0 tsconfig-paths: 4.2.0 validate-npm-package-name: 7.0.2 - zod: 4.3.6 - zod-to-json-schema: 3.25.1(zod@4.3.6) + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) transitivePeerDependencies: - '@cfworker/json-schema' - '@types/node' @@ -27557,15 +27535,15 @@ snapshots: yoga-wasm-web@0.3.3: {} - zod-to-json-schema@3.25.1(zod@4.3.6): + zod-to-json-schema@3.25.1(zod@3.25.76): dependencies: - zod: 4.3.6 + zod: 3.25.76 zod-to-json-schema@3.25.1(zod@4.4.3): dependencies: zod: 4.4.3 - zod@4.3.6: {} + zod@3.25.76: {} zod@4.4.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 84380c288f..90a06407a5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,11 +11,15 @@ catalog: '@posthog/quill': 0.3.0-beta.19 '@radix-ui/themes': ^3.2.1 '@tanstack/devtools-vite': ^0.7.0 - '@tanstack/query-async-storage-persister': ^5.90.2 - '@tanstack/query-sync-storage-persister': ^5.90.2 + # Pinned to react-query's resolved version (not ^) so the persist packages + # share a single @tanstack/query-core with react-query. A floating ^ resolves + # to a newer minor (5.101.x) and pulls in a second query-core, which can make + # the persist/restore machinery operate on mismatched Query internals. + '@tanstack/query-async-storage-persister': 5.90.20 + '@tanstack/query-sync-storage-persister': 5.90.20 '@tanstack/react-devtools': ^0.10.5 '@tanstack/react-query': ^5.90.2 - '@tanstack/react-query-persist-client': ^5.90.2 + '@tanstack/react-query-persist-client': 5.90.20 '@tanstack/react-router': ^1.95.0 '@tanstack/react-router-devtools': ^1.95.0 '@tanstack/router-generator': ^1.95.0 From 532ab46339d44eb5a1a0168fca86c9b0663910a2 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Sat, 27 Jun 2026 16:20:04 -0700 Subject: [PATCH 3/3] fix(folder-picker): import toast from ui primitive, not sonner 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 --- packages/ui/src/features/folder-picker/FolderPicker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/features/folder-picker/FolderPicker.tsx b/packages/ui/src/features/folder-picker/FolderPicker.tsx index 6c802ee07e..ef66040878 100644 --- a/packages/ui/src/features/folder-picker/FolderPicker.tsx +++ b/packages/ui/src/features/folder-picker/FolderPicker.tsx @@ -26,10 +26,10 @@ import { MenuLabel, } from "@posthog/quill"; import { useFolders } from "@posthog/ui/features/folders/useFolders"; +import { toast } from "@posthog/ui/primitives/toast"; import { FIELD_TRIGGER_CLASS } from "@posthog/ui/styles/fieldTrigger"; import { Flex, Text } from "@radix-ui/themes"; import { type RefObject, useState } from "react"; -import { toast } from "sonner"; interface FolderPickerProps { value: string;