Skip to content

Commit ec70c4d

Browse files
authored
improvement(nav): cut prefetch and session-recorder waste (#6656)
- 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.
1 parent 29853fb commit ec70c4d

2 files changed

Lines changed: 94 additions & 33 deletions

File tree

apps/sim/app/_shell/providers/posthog-provider.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,28 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) {
4242
password: true,
4343
email: false,
4444
},
45+
/**
46+
* None of these nodes are painted, so replay fidelity is
47+
* unchanged, while each full snapshot serializes fewer nodes on
48+
* the main thread and ships a smaller payload.
49+
*
50+
* Enumerated rather than `true`/`'all'` on purpose — those
51+
* presets also enable `headTitleMutations`, which would drop
52+
* `document.title` changes and lose the page identity a replay
53+
* viewer reads while scrubbing.
54+
*/
55+
slimDOMOptions: {
56+
script: true,
57+
comment: true,
58+
headFavicon: true,
59+
headWhitespace: true,
60+
headMetaDescKeywords: true,
61+
headMetaSocial: true,
62+
headMetaRobots: true,
63+
headMetaHttpEquiv: true,
64+
headMetaAuthorship: true,
65+
headMetaVerification: true,
66+
},
4567
recordCrossOriginIframes: false,
4668
recordHeaders: false,
4769
recordBody: false,

apps/sim/app/workspace/[workspaceId]/prefetch.ts

Lines changed: 72 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
13
import type { QueryClient } from '@tanstack/react-query'
24
import { listWorkspacesContract, type WorkspaceHostContext } from '@/lib/api/contracts/workspaces'
35
import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats'
@@ -21,10 +23,7 @@ import {
2123
import { FOLDER_LIST_STALE_TIME, folderKeys, mapFolder } from '@/hooks/queries/utils/folder-keys'
2224
import { workflowKeys } from '@/hooks/queries/utils/workflow-keys'
2325
import { mapWorkflow, WORKFLOW_LIST_STALE_TIME } from '@/hooks/queries/utils/workflow-list-query'
24-
import {
25-
normalizeWorkspacesResponse,
26-
WORKSPACE_LIST_STALE_TIME,
27-
} from '@/hooks/queries/utils/workspace-list-query'
26+
import { normalizeWorkspacesResponse } from '@/hooks/queries/utils/workspace-list-query'
2827
import { WORKSPACE_PERMISSIONS_STALE_TIME, workspaceKeys } from '@/hooks/queries/workspace'
2928
import {
3029
WORKSPACE_HOST_CONTEXT_STALE_TIME,
@@ -47,22 +46,81 @@ export function prefetchWorkspaceHostContext(
4746
})
4847
}
4948

49+
const logger = createLogger('WorkspacePrefetch')
50+
51+
/**
52+
* Seeds the viewer's workspace list, which the switcher reads.
53+
*
54+
* Seeded rather than prefetched so the empty-list case can decline to create a
55+
* cache entry at all: the route's default-workspace creation path must run on
56+
* the client, and an entry — even an empty one — would suppress it. Expressing
57+
* that as an absent seed keeps a normal state out of the error channel, where
58+
* it previously cost a full second re-read (`retry: 1`) to re-derive an outcome
59+
* already known.
60+
*/
61+
async function seedWorkspaceList(
62+
queryClient: QueryClient,
63+
userId: string,
64+
activeOrganizationId: string | null
65+
): Promise<void> {
66+
try {
67+
const payload = await listWorkspacesForViewer({
68+
userId,
69+
activeOrganizationId,
70+
scope: 'active',
71+
})
72+
if (payload.workspaces.length === 0) return
73+
/**
74+
* Parsing through the route contract's response schema strips the same
75+
* server-only fields `requestJson` strips on the client, guaranteeing the
76+
* seeded shape is identical to a client fetch.
77+
*/
78+
queryClient.setQueryData(
79+
workspaceKeys.list('active'),
80+
normalizeWorkspacesResponse(listWorkspacesContract.response.schema.parse(payload))
81+
)
82+
} catch (error) {
83+
/**
84+
* Swallowed rather than rethrown — this read is an optimization; the layout
85+
* renders fine without it and the client fetch reaches the route instead.
86+
* Logged because contract drift between the read and the response schema
87+
* would otherwise degrade silently into every viewer waterfalling.
88+
*/
89+
logger.warn('Workspace list seed failed; client will fetch', {
90+
error: getErrorMessage(error),
91+
})
92+
}
93+
}
94+
5095
/**
5196
* Prefetches the sidebar's workflow, chat, folder, workspace-permissions,
5297
* workspace, and viewer-profile reads for a workspace and stores them under the
5398
* same query keys + mappers the client hooks use, so the persistent sidebar
54-
* (including the workspace switcher header and the footer's profile row) paints
55-
* populated on the first server render
56-
* instead of flashing skeletons on a cold load (e.g. after the browser
57-
* discards an idle tab). Calls the data layer directly — the same functions
58-
* the API routes use — with no internal HTTP hop.
99+
* (including the workspace switcher header and the footer's profile row) is
100+
* populated without a client-side request waterfall on a cold load (e.g. after
101+
* the browser discards an idle tab). Calls the data layer directly — the same
102+
* functions the API routes use — with no internal HTTP hop.
59103
*
60104
* The host context is the authorization proof for this server-render pass, so
61105
* permission prefetch can reuse its effective permission without repeating
62106
* workspace and membership reads. It also proves the viewer has at least one
63-
* accessible workspace, which is why the workspace-list prefetch can safely
64-
* skip the route's empty-list default-workspace creation path — and the
65-
* route's orphaned-workflow repair, which still runs on client refetches.
107+
* accessible workspace, so this pass skips the route's orphaned-workflow
108+
* repair, which still runs on client refetches.
109+
*
110+
* All reads run concurrently and are awaited together, so every pane is settled
111+
* in the cache before `dehydrate` and the sidebar still paints populated rather
112+
* than flashing skeletons that stream in behind the shell.
113+
*
114+
* The workspace list is seeded rather than prefetched. An empty or failed read
115+
* seeds nothing, leaving the client fetch to reach `GET /api/workspaces`'
116+
* default-workspace creation path — the same outcome a rejecting `queryFn` used
117+
* to produce, without routing a normal state through the error channel. That
118+
* matters because `makeQueryClient` dehydrates pending queries and sets
119+
* `retryOnMount: false`: were this read ever deferred, its rejection would
120+
* hydrate the client query into an error state nothing retries, permanently
121+
* locking a brand-new viewer out of workspace creation. Seeding also skips the
122+
* `retry: 1` default, which previously ran the whole read a second time, a
123+
* retry delay later, purely to re-derive an outcome already known.
66124
*/
67125
export async function prefetchWorkspaceSidebar(
68126
queryClient: QueryClient,
@@ -72,6 +130,7 @@ export async function prefetchWorkspaceSidebar(
72130
activeOrganizationId: string | null
73131
): Promise<void> {
74132
if (hostContext.workspace.id !== workspaceId) return
133+
75134
await Promise.all([
76135
queryClient.prefetchQuery({
77136
queryKey: workflowKeys.list(workspaceId, 'active'),
@@ -101,27 +160,6 @@ export async function prefetchWorkspaceSidebar(
101160
},
102161
staleTime: FOLDER_LIST_STALE_TIME,
103162
}),
104-
queryClient.prefetchQuery({
105-
queryKey: workspaceKeys.list('active'),
106-
queryFn: async () => {
107-
const payload = await listWorkspacesForViewer({
108-
userId,
109-
activeOrganizationId,
110-
scope: 'active',
111-
})
112-
// An empty list means GET /api/workspaces' default-workspace creation
113-
// path must run — throw so prefetchQuery caches nothing and the client
114-
// fetch reaches the route.
115-
if (payload.workspaces.length === 0) {
116-
throw new Error('Empty workspace list requires the route creation path')
117-
}
118-
// Parsing through the route contract's response schema strips the same
119-
// server-only fields `requestJson` strips on the client, guaranteeing the
120-
// cached shape is identical to a client fetch.
121-
return normalizeWorkspacesResponse(listWorkspacesContract.response.schema.parse(payload))
122-
},
123-
staleTime: WORKSPACE_LIST_STALE_TIME,
124-
}),
125163
queryClient.prefetchQuery({
126164
queryKey: workspaceKeys.permissions(workspaceId),
127165
queryFn: () =>
@@ -148,5 +186,6 @@ export async function prefetchWorkspaceSidebar(
148186
},
149187
staleTime: USER_PROFILE_STALE_TIME,
150188
}),
189+
seedWorkspaceList(queryClient, userId, activeOrganizationId),
151190
])
152191
}

0 commit comments

Comments
 (0)