From 0936ab60761b8362a289e756a77cc3c676b4ba7c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 21:13:06 -0700 Subject: [PATCH] improvement(nav): cut prefetch and session-recorder waste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Seed the workspace list instead of prefetching it. The empty-list case was signalled by throwing inside queryFn, which the retry: 1 default re-ran the entire read to re-derive, a retry delay later. Log the failure path, which was silent — contract drift would have degraded into every viewer waterfalling with nothing in the logs. - Drop non-painted nodes from rrweb snapshots via slimDOMOptions. Enumerated rather than true/'all' so headTitleMutations stays off and replays keep document.title. Prefetch concurrency and await semantics are unchanged, so sidebar paint timing matches staging. --- .../app/_shell/providers/posthog-provider.tsx | 22 ++++ .../app/workspace/[workspaceId]/prefetch.ts | 105 ++++++++++++------ 2 files changed, 94 insertions(+), 33 deletions(-) diff --git a/apps/sim/app/_shell/providers/posthog-provider.tsx b/apps/sim/app/_shell/providers/posthog-provider.tsx index 368bb3fc913..17e3f8c040b 100644 --- a/apps/sim/app/_shell/providers/posthog-provider.tsx +++ b/apps/sim/app/_shell/providers/posthog-provider.tsx @@ -42,6 +42,28 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) { password: true, email: false, }, + /** + * None of these nodes are painted, so replay fidelity is + * unchanged, while each full snapshot serializes fewer nodes on + * the main thread and ships a smaller payload. + * + * Enumerated rather than `true`/`'all'` on purpose — those + * presets also enable `headTitleMutations`, which would drop + * `document.title` changes and lose the page identity a replay + * viewer reads while scrubbing. + */ + slimDOMOptions: { + script: true, + comment: true, + headFavicon: true, + headWhitespace: true, + headMetaDescKeywords: true, + headMetaSocial: true, + headMetaRobots: true, + headMetaHttpEquiv: true, + headMetaAuthorship: true, + headMetaVerification: true, + }, recordCrossOriginIframes: false, recordHeaders: false, recordBody: false, diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/prefetch.ts index fe69e488fae..c15cdbb5bad 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/prefetch.ts @@ -1,3 +1,5 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import type { QueryClient } from '@tanstack/react-query' import { listWorkspacesContract, type WorkspaceHostContext } from '@/lib/api/contracts/workspaces' import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats' @@ -21,10 +23,7 @@ import { import { FOLDER_LIST_STALE_TIME, folderKeys, mapFolder } from '@/hooks/queries/utils/folder-keys' import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' import { mapWorkflow, WORKFLOW_LIST_STALE_TIME } from '@/hooks/queries/utils/workflow-list-query' -import { - normalizeWorkspacesResponse, - WORKSPACE_LIST_STALE_TIME, -} from '@/hooks/queries/utils/workspace-list-query' +import { normalizeWorkspacesResponse } from '@/hooks/queries/utils/workspace-list-query' import { WORKSPACE_PERMISSIONS_STALE_TIME, workspaceKeys } from '@/hooks/queries/workspace' import { WORKSPACE_HOST_CONTEXT_STALE_TIME, @@ -47,22 +46,81 @@ export function prefetchWorkspaceHostContext( }) } +const logger = createLogger('WorkspacePrefetch') + +/** + * Seeds the viewer's workspace list, which the switcher reads. + * + * Seeded rather than prefetched so the empty-list case can decline to create a + * cache entry at all: the route's default-workspace creation path must run on + * the client, and an entry — even an empty one — would suppress it. Expressing + * that as an absent seed keeps a normal state out of the error channel, where + * it previously cost a full second re-read (`retry: 1`) to re-derive an outcome + * already known. + */ +async function seedWorkspaceList( + queryClient: QueryClient, + userId: string, + activeOrganizationId: string | null +): Promise { + try { + const payload = await listWorkspacesForViewer({ + userId, + activeOrganizationId, + scope: 'active', + }) + if (payload.workspaces.length === 0) return + /** + * Parsing through the route contract's response schema strips the same + * server-only fields `requestJson` strips on the client, guaranteeing the + * seeded shape is identical to a client fetch. + */ + queryClient.setQueryData( + workspaceKeys.list('active'), + normalizeWorkspacesResponse(listWorkspacesContract.response.schema.parse(payload)) + ) + } catch (error) { + /** + * Swallowed rather than rethrown — this read is an optimization; the layout + * renders fine without it and the client fetch reaches the route instead. + * Logged because contract drift between the read and the response schema + * would otherwise degrade silently into every viewer waterfalling. + */ + logger.warn('Workspace list seed failed; client will fetch', { + error: getErrorMessage(error), + }) + } +} + /** * Prefetches the sidebar's workflow, chat, folder, workspace-permissions, * workspace, and viewer-profile reads for a workspace and stores them under the * same query keys + mappers the client hooks use, so the persistent sidebar - * (including the workspace switcher header and the footer's profile row) paints - * populated on the first server render - * instead of flashing skeletons on a cold load (e.g. after the browser - * discards an idle tab). Calls the data layer directly — the same functions - * the API routes use — with no internal HTTP hop. + * (including the workspace switcher header and the footer's profile row) is + * populated without a client-side request waterfall on a cold load (e.g. after + * the browser discards an idle tab). Calls the data layer directly — the same + * functions the API routes use — with no internal HTTP hop. * * The host context is the authorization proof for this server-render pass, so * permission prefetch can reuse its effective permission without repeating * workspace and membership reads. It also proves the viewer has at least one - * accessible workspace, which is why the workspace-list prefetch can safely - * skip the route's empty-list default-workspace creation path — and the - * route's orphaned-workflow repair, which still runs on client refetches. + * accessible workspace, so this pass skips the route's orphaned-workflow + * repair, which still runs on client refetches. + * + * All reads run concurrently and are awaited together, so every pane is settled + * in the cache before `dehydrate` and the sidebar still paints populated rather + * than flashing skeletons that stream in behind the shell. + * + * The workspace list is seeded rather than prefetched. An empty or failed read + * seeds nothing, leaving the client fetch to reach `GET /api/workspaces`' + * default-workspace creation path — the same outcome a rejecting `queryFn` used + * to produce, without routing a normal state through the error channel. That + * matters because `makeQueryClient` dehydrates pending queries and sets + * `retryOnMount: false`: were this read ever deferred, its rejection would + * hydrate the client query into an error state nothing retries, permanently + * locking a brand-new viewer out of workspace creation. Seeding also skips the + * `retry: 1` default, which previously ran the whole read a second time, a + * retry delay later, purely to re-derive an outcome already known. */ export async function prefetchWorkspaceSidebar( queryClient: QueryClient, @@ -72,6 +130,7 @@ export async function prefetchWorkspaceSidebar( activeOrganizationId: string | null ): Promise { if (hostContext.workspace.id !== workspaceId) return + await Promise.all([ queryClient.prefetchQuery({ queryKey: workflowKeys.list(workspaceId, 'active'), @@ -101,27 +160,6 @@ export async function prefetchWorkspaceSidebar( }, staleTime: FOLDER_LIST_STALE_TIME, }), - queryClient.prefetchQuery({ - queryKey: workspaceKeys.list('active'), - queryFn: async () => { - const payload = await listWorkspacesForViewer({ - userId, - activeOrganizationId, - scope: 'active', - }) - // An empty list means GET /api/workspaces' default-workspace creation - // path must run — throw so prefetchQuery caches nothing and the client - // fetch reaches the route. - if (payload.workspaces.length === 0) { - throw new Error('Empty workspace list requires the route creation path') - } - // Parsing through the route contract's response schema strips the same - // server-only fields `requestJson` strips on the client, guaranteeing the - // cached shape is identical to a client fetch. - return normalizeWorkspacesResponse(listWorkspacesContract.response.schema.parse(payload)) - }, - staleTime: WORKSPACE_LIST_STALE_TIME, - }), queryClient.prefetchQuery({ queryKey: workspaceKeys.permissions(workspaceId), queryFn: () => @@ -148,5 +186,6 @@ export async function prefetchWorkspaceSidebar( }, staleTime: USER_PROFILE_STALE_TIME, }), + seedWorkspaceList(queryClient, userId, activeOrganizationId), ]) }