mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<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,
|
||||
@@ -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'),
|
||||
@@ -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),
|
||||
])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user