diff --git a/apps/code/package.json b/apps/code/package.json
index 19ca6a9abc..f313349a2a 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.20",
"@tanstack/react-query": "^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/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..b50ec73587 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.20",
"@tanstack/react-query": "^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/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/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/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..e48184482e
--- /dev/null
+++ b/packages/core/src/canvas/channelTasksService.test.ts
@@ -0,0 +1,73 @@
+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.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", channelPath);
+
+ 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(channelPath ?? "Channels/chan-1"),
+ );
+ expect(suffix).toContain("type=task");
+ });
+
+ 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..38a1635571 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,30 @@ describe("DashboardsService.list", () => {
expect(query).toContain("type=dashboard");
});
+ 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", channelPath);
+
+ expect(getEntry).toHaveBeenCalledTimes(getEntryCalls);
+ const [query] = listByQuery.mock.calls[0];
+ expect(query).toContain(
+ encodeURIComponent(channelPath ?? "Channels/chan-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..8044ab4110 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,14 +48,25 @@ 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. 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(
+ 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/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/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;
diff --git a/packages/ui/src/shell/queryPersistence.test.ts b/packages/ui/src/shell/queryPersistence.test.ts
new file mode 100644
index 0000000000..b669f9d5d7
--- /dev/null
+++ b/packages/ui/src/shell/queryPersistence.test.ts
@@ -0,0 +1,57 @@
+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.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.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
new file mode 100644
index 0000000000..ede2af1bc8
--- /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 the predicate stays assignable to `shouldDehydrateQuery`
+// without coupling to query-core's exact `Query` type.
+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..52266609ca 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.20
+ version: 5.90.20
'@tanstack/react-router':
specifier: ^1.95.0
version: 1.170.15
@@ -83,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:
@@ -130,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
@@ -212,9 +214,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.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.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))
@@ -312,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
@@ -664,9 +672,15 @@ importers:
'@posthog/workspace-client':
specifier: workspace:*
version: link:../../packages/workspace-client
+ '@tanstack/query-sync-storage-persister':
+ 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.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)
@@ -712,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
@@ -765,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:*
@@ -822,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)
@@ -857,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)
@@ -928,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:
@@ -1055,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)
@@ -1365,6 +1379,9 @@ importers:
'@tanstack/react-query':
specifier: 'catalog:'
version: 5.90.20(react@19.1.0)
+ '@tanstack/react-query-persist-client':
+ specifier: 'catalog:'
+ 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
@@ -1446,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)
@@ -1511,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
@@ -1560,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==}
@@ -1674,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==}
@@ -1682,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
@@ -3729,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==}
@@ -3946,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
@@ -3958,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
@@ -3968,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
@@ -4578,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
@@ -6016,9 +6033,18 @@ packages:
resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==}
engines: {node: '>=20.19'}
+ '@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.91.17':
+ resolution: {integrity: sha512-NfXUCUzar8Y3fw0F6lPlrnJWfg148IXvWBIr/0x2LbYAoGdAnJDU3PzVaLTafI2vKQqIphe3uAq1068+nhn+nQ==}
+
+ '@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==}
engines: {node: '>=18'}
@@ -6028,6 +6054,12 @@ packages:
react: '>=16.8'
react-dom: '>=16.8'
+ '@tanstack/react-query-persist-client@5.90.20':
+ resolution: {integrity: sha512-FiKLxu7haxUAVV9hUEaUkUF6CsOM8i0ERtE6ETLQ0t5aKefY9tRByP9J1C4qf1r1m2TSWV0WwkmOsmsy1pZqsQ==}
+ peerDependencies:
+ '@tanstack/react-query': ^5.90.18
+ react: ^18 || ^19
+
'@tanstack/react-query@5.90.20':
resolution: {integrity: sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==}
peerDependencies:
@@ -13527,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==}
@@ -13566,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': {}
@@ -13637,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
@@ -13652,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
@@ -13667,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:
@@ -16188,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:
@@ -16198,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:
@@ -16206,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:
@@ -16215,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:
@@ -16223,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:
@@ -16246,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:
@@ -16269,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:
@@ -16282,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:
@@ -16302,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:
@@ -16311,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:
@@ -16358,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
@@ -16625,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)
@@ -16641,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
@@ -16658,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
@@ -16680,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
@@ -18742,8 +18758,22 @@ snapshots:
'@tanstack/history@1.162.0': {}
+ '@tanstack/query-async-storage-persister@5.90.20':
+ dependencies:
+ '@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.91.17':
+ dependencies:
+ '@tanstack/query-core': 5.90.20
+
+ '@tanstack/query-sync-storage-persister@5.90.20':
+ dependencies:
+ '@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:
'@tanstack/devtools': 0.12.2(csstype@3.2.3)(solid-js@1.9.13)
@@ -18757,6 +18787,12 @@ snapshots:
- solid-js
- utf-8-validate
+ '@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.91.17
+ '@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
@@ -18819,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
@@ -18833,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)
@@ -22923,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: {}
@@ -25860,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
@@ -25887,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'
@@ -27499,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 409b55ce1f..90a06407a5 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -11,8 +11,15 @@ catalog:
'@posthog/quill': 0.3.0-beta.19
'@radix-ui/themes': ^3.2.1
'@tanstack/devtools-vite': ^0.7.0
+ # 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.20
'@tanstack/react-router': ^1.95.0
'@tanstack/react-router-devtools': ^1.95.0
'@tanstack/router-generator': ^1.95.0