Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/sim/app/_shell/providers/posthog-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
105 changes: 72 additions & 33 deletions apps/sim/app/workspace/[workspaceId]/prefetch.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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,
Expand All @@ -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<void> {
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,
Expand All @@ -72,6 +130,7 @@ export async function prefetchWorkspaceSidebar(
activeOrganizationId: string | null
): Promise<void> {
if (hostContext.workspace.id !== workspaceId) return

await Promise.all([
queryClient.prefetchQuery({
queryKey: workflowKeys.list(workspaceId, 'active'),
Expand Down Expand Up @@ -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: () =>
Expand All @@ -148,5 +186,6 @@ export async function prefetchWorkspaceSidebar(
},
staleTime: USER_PROFILE_STALE_TIME,
}),
seedWorkspaceList(queryClient, userId, activeOrganizationId),
])
}
Loading