client-providers.tsx7.9 KBView on GitHub import type { ChatExtras } from '@/modules/cedar-os/src/chat-extras';
import { useCanvases } from '@/modules/canvas/hooks/use-canvases';
import { useSettings } from '@/modules/userSettings/hooks/use-settings';
import { LoadingProvider } from '@/components/context/loading-context';
import { AdministeredUserProvider } from '@/modules/administeredUser';
import { useSession } from '@/modules/auth/utils/auth-client';
import { markSessionSettled } from '@/lib/session-ready';
import { getRuntimeBackendUrl } from '@/lib/runtime-urls';
import { NuqsAdapter } from 'nuqs/adapters/react-router/v7';
import { SidebarProvider } from '@/components/ui/sidebar';
import { selectChatThreadId } from '@/modules/ux/uxSlice';
import { PostHogProvider } from '@/lib/posthog-provider';
// Import the component directly, NOT via the `@/modules/cedar-os/src` barrel. The barrel
// re-exports `useCedarEditor`, which imports `@tiptap/extension-emoji` — pulling the entire
// GitHub emoji dataset (~580 KB) onto the critical path of every route, login included.
// `apps/mail/package.json`'s `sideEffects` list does not cover that barrel, so Rollup treats the
// re-export as load-bearing and cannot shake it out. See docs/design/frontend-load-performance.md §2.3.
import { CedarCopilot } from '@/modules/cedar-os/src/components/CedarCopilot';
import { ThemeProvider, useTheme } from 'next-themes';
import { useRealThemeDriver } from '@/modules/userSettings/theme/use-real-theme';
import { Provider as JotaiProvider } from 'jotai';
import { useCedarStore, useTranscriptModal } from '@/modules/store';
import type { PropsWithChildren } from 'react';
import Toaster from '@/components/ui/toast';
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
/**
* The transcript viewer, behind a dynamic-import boundary AND behind its own open flag.
*
* It pulls `MarkdownRenderer` -> `react-markdown` (plus the remark/rehype/micromark stack), and
* `ClientProviders` renders on every route — so a static import here put ~1.1 MB of markdown
* machinery in the chunk `index.html` modulepreloads, on `/login` and `/calendar` included. That
* is the same leak `modules/cedar-os/src/chat-extras.ts` closes on the chat side; this was the
* surviving path.
*
* `useTranscriptModal` lives in the eager root store, so reading `isOpen` costs nothing. Mounting
* only while open means the chunk is fetched the first time someone clicks a citation, which is
* also the first moment its content could be visible.
*/
const LazyTranscriptViewerModal = lazy(() =>
import('@/components/ui/citations/TranscriptViewerModal').then((m) => ({
default: m.TranscriptViewerModal,
})),
);
function TranscriptViewerModalGate() {
const { isOpen } = useTranscriptModal();
if (!isOpen) return null;
return (
<Suspense fallback={null}>
<LazyTranscriptViewerModal />
</Suspense>
);
}
/**
* Stable empty defaults. `CedarCopilot` registers renderers/processors in effects keyed on these
* arrays, so a fresh `[]` literal per render would register-and-unregister on every pass.
*/
const NO_RENDERERS: ChatExtras['messageRenderers'] = [];
const NO_PROCESSORS: ChatExtras['responseProcessors'] = [];
/**
* Load the chat renderers and response processors after mount instead of at the root.
*
* They are only read inside CedarCopilot's registration effects, which key on the arrays — so
* arriving a tick late is fine, and it keeps react-markdown, DOMPurify, motion and the tool-mapping
* table off the critical path. See modules/cedar-os/src/chat-extras.ts.
*/
function useChatExtras(): ChatExtras {
const [extras, setExtras] = useState<ChatExtras | null>(null);
useEffect(() => {
let cancelled = false;
import('@/modules/cedar-os/src/chat-extras')
.then(({ getChatExtras }) => {
if (!cancelled) setExtras(getChatExtras());
})
.catch((err) => {
console.error('[ClientProviders] failed to load chat extras', err);
});
return () => {
cancelled = true;
};
}, []);
return {
messageRenderers: extras?.messageRenderers ?? NO_RENDERERS,
responseProcessors: extras?.responseProcessors ?? NO_PROCESSORS,
};
}
// Sync theme from database to next-themes on initial mount only.
// localStorage is checked first — if the user has an explicit local preference
// already stored, we leave it alone. Only apply the DB value when there is no
// local preference (e.g. first load on a new device).
function ThemeSync() {
const { data } = useSettings();
const { setTheme } = useTheme();
const hasInitialized = useRef(false);
useEffect(() => {
if (hasInitialized.current || !data?.settings?.colorTheme) return;
hasInitialized.current = true;
// The 'real' theme is driven by useRealThemeDriver, not next-themes directly.
if (data.settings.colorTheme === 'real') return;
const stored = localStorage.getItem('cedar-mail-theme');
// If localStorage already has an explicit, non-default preference, respect it.
if (stored && stored !== 'system') return;
setTheme(data.settings.colorTheme);
}, [data?.settings?.colorTheme, setTheme]);
useRealThemeDriver();
return null;
}
// Prefetch HomeView canvases early so they're ready when HomeView mounts
function HomeViewConfigPrefetch() {
// Fetches canvases and seeds canvasSlice (staleTime: 30s)
useCanvases();
return null;
}
export function ClientProviders({ children }: PropsWithChildren) {
const { data } = useSettings();
const { data: session, isPending: isSessionPending } = useSession();
const chatThreadId = useCedarStore(selectChatThreadId);
// Release the tRPC transport once better-auth has answered "is there a
// session?" — either way. Until this trips, requests queue in the fetch
// handler instead of going out cookie-less and 401ing (see lib/session-ready).
// This provider wraps every route, including /login, so the latch trips for
// signed-out users too and public pages are not held past their session check.
useEffect(() => {
if (!isSessionPending) markSessionSettled();
}, [isSessionPending]);
// next-themes doesn't know about 'real' — the driver applies a concrete
// light/dark value at runtime. Start in 'system' so next-themes initializes
// sensibly; the driver will override once settings load.
const persistedTheme = data?.settings.colorTheme;
const theme = !persistedTheme || persistedTheme === 'real' ? 'system' : persistedTheme;
const backendUrl = getRuntimeBackendUrl() || 'https://modern-lemon-whale.mastra.cloud';
const { messageRenderers, responseProcessors } = useChatExtras();
return (
<NuqsAdapter>
<JotaiProvider>
<ThemeProvider
attribute="class"
enableSystem
disableTransitionOnChange
defaultTheme={theme}
storageKey=[redacted]
enableColorScheme={false}
>
<ThemeSync />
<HomeViewConfigPrefetch />
<SidebarProvider>
<PostHogProvider>
<LoadingProvider>
<AdministeredUserProvider>
<CedarCopilot
userId={session?.user?.id ?? null}
threadId={chatThreadId}
llmProvider={{
provider: 'mastra',
baseURL: backendUrl,
chatPath: '/api/trpc/mastra.chat',
voiceRoute: '/ai/voice',
}}
messageStorage={{
type: 'database',
}}
messageRenderers={messageRenderers}
responseProcessors={responseProcessors}
>
{children}
<Toaster />
<TranscriptViewerModalGate />
</CedarCopilot>
</AdministeredUserProvider>
</LoadingProvider>
</PostHogProvider>
</SidebarProvider>
</ThemeProvider>
</JotaiProvider>
</NuqsAdapter>
);
}