1+ import { createLogger } from '@sim/logger'
2+ import { getErrorMessage } from '@sim/utils/errors'
13import type { QueryClient } from '@tanstack/react-query'
24import { listWorkspacesContract , type WorkspaceHostContext } from '@/lib/api/contracts/workspaces'
35import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats'
@@ -21,10 +23,7 @@ import {
2123import { FOLDER_LIST_STALE_TIME , folderKeys , mapFolder } from '@/hooks/queries/utils/folder-keys'
2224import { workflowKeys } from '@/hooks/queries/utils/workflow-keys'
2325import { 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'
2827import { WORKSPACE_PERMISSIONS_STALE_TIME , workspaceKeys } from '@/hooks/queries/workspace'
2928import {
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 */
67125export 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