diff --git a/js/app/packages/app/component/Launcher.tsx b/js/app/packages/app/component/Launcher.tsx
index ac0cf8c274..48e1c536b2 100644
--- a/js/app/packages/app/component/Launcher.tsx
+++ b/js/app/packages/app/component/Launcher.tsx
@@ -1,6 +1,6 @@
import { analytics } from '@app/lib/analytics';
import { useFeatureFlag } from '@app/lib/analytics/posthog';
-import { setAutomationComposerOpen } from '@block-automation/component';
+import { setAutomationComposerOpen } from '@block-automation/component/automationComposerState';
import type { BlockAlias, BlockName } from '@core/block';
import { getIconConfig } from '@core/component/EntityIcon';
import {
diff --git a/js/app/packages/app/component/Layout.tsx b/js/app/packages/app/component/Layout.tsx
index 9b9af81ddb..73a3f01478 100644
--- a/js/app/packages/app/component/Layout.tsx
+++ b/js/app/packages/app/component/Layout.tsx
@@ -8,12 +8,13 @@ import {
} from '@app/component/sidebarVisibility';
import { ROUTER_BASE_CONCAT } from '@app/constants/routerBase';
import { mountGlobalFocusListener } from '@app/signal/focus';
-import { AutomationComposer } from '@block-automation/component';
+import { automationComposerOpen } from '@block-automation/component/automationComposerState';
import { useIsAuthenticated } from '@core/auth';
import { usePaywallState } from '@core/constant/PaywallState';
import { isMobile } from '@core/mobile/isMobile';
import { virtualKeyboardVisible } from '@core/mobile/virtualKeyboard';
import { updateCookie } from '@core/util/cookies';
+import { getPlatform } from '@core/util/platform';
import { makePersisted } from '@solid-primitives/storage';
import { type RouteSectionProps, useLocation } from '@solidjs/router';
import { cn, Layer } from '@ui';
@@ -23,6 +24,7 @@ import {
createEffect,
createMemo,
createSignal,
+ lazy,
onMount,
Show,
Suspense,
@@ -37,7 +39,6 @@ import GlobalShortcuts from './GlobalHotkeys';
import { GmailReauthenticationPrompt } from './GmailReauthenticationPrompt';
import { GlobalShareModal } from './global-share-modal/GlobalShareModal';
import { ItemDndProvider } from './ItemDragAndDrop';
-import { IosShareSheet } from './ios-share-sheet/IosShareSheet';
import { createMenuOpen, Launcher, setCreateMenuOpen } from './Launcher';
import { MacroMcpSetupModal } from './macro-mcp-setup-modal/MacroMcpSetupModal';
import { MobileDock } from './mobile/MobileDock';
@@ -47,6 +48,23 @@ import { Paywall } from './paywall/Paywall';
import { PropertyEditorModal } from './property-edit-modal/PropertyEditorModal';
import { useAppSquishHandlers } from './useAppSquishHandlers';
+// Lazy + iOS-gated: the share sheet pulls in the channel input / markdown
+// editor stack, which would otherwise load with the initial bundle on every
+// platform.
+// Lazy + open-gated: the composer pulls in the markdown editor stack. The
+// chunk loads the first time the composer is opened.
+const AutomationComposer = lazy(() =>
+ import('@block-automation/component/AutomationComposer').then((m) => ({
+ default: m.AutomationComposer,
+ }))
+);
+
+const IosShareSheet = lazy(() =>
+ import('./ios-share-sheet/IosShareSheet').then((m) => ({
+ default: m.IosShareSheet,
+ }))
+);
+
const AUTH_URLS = [
`${ROUTER_BASE_CONCAT}login`,
`${ROUTER_BASE_CONCAT}login/popup`,
@@ -153,7 +171,9 @@ function LayoutInner(props: RouteSectionProps) {
-
+
+
+
-
+
+
+
+
+
diff --git a/js/app/packages/app/component/Root.tsx b/js/app/packages/app/component/Root.tsx
index 2f06a8f711..c69d98a8f9 100644
--- a/js/app/packages/app/component/Root.tsx
+++ b/js/app/packages/app/component/Root.tsx
@@ -8,9 +8,9 @@ import { ROUTER_BASE } from '@app/constants/routerBase';
import { PosthogProvider, usePosthog } from '@app/lib/analytics/posthog';
import { setHotkeyRoot } from '@app/signal/hotkeyRoot';
import { globalSplitManager } from '@app/signal/splitLayout';
-import { CallKitSync } from '@channel/Call';
import { CallProvider } from '@channel/Call/CallContext';
import { CallStartedNotifier } from '@channel/Call/CallStartedNotifier';
+import { CallKitSync } from '@channel/Call/use-callkit';
import { ChatAttachmentsInit } from '@core/component/AI/signal/globalAttachments';
import { toast } from '@core/component/Toast/Toast';
import { ToastRegion } from '@core/component/Toast/ToastRegion';
diff --git a/js/app/packages/app/component/app-sidebar/active-call-widget.tsx b/js/app/packages/app/component/app-sidebar/active-call-widget.tsx
index d0def3f3fc..36cc86cea9 100644
--- a/js/app/packages/app/component/app-sidebar/active-call-widget.tsx
+++ b/js/app/packages/app/component/app-sidebar/active-call-widget.tsx
@@ -1,9 +1,7 @@
-import {
- joinChannelCall,
- openChannelCallTab,
- stopCallRinger,
- useCallContextOptional,
-} from '@channel/Call';
+import { useCallContextOptional } from '@channel/Call/CallContext';
+import { stopCallRinger } from '@channel/Call/CallStartedNotifier';
+import { joinChannelCall } from '@channel/Call/join-channel-call';
+import { openChannelCallTab } from '@channel/Call/open-channel-call-tab';
import { ContextMenuContent, MenuItem } from '@core/component/ContextMenu';
import { ENABLE_CALLS } from '@core/constant/featureFlags';
import { useChannelsContext } from '@core/context/channels';
diff --git a/js/app/packages/app/component/app-sidebar/sidebar.tsx b/js/app/packages/app/component/app-sidebar/sidebar.tsx
index 8ffed4626b..65dbb9a3b1 100644
--- a/js/app/packages/app/component/app-sidebar/sidebar.tsx
+++ b/js/app/packages/app/component/app-sidebar/sidebar.tsx
@@ -33,8 +33,8 @@ import {
import { useFeatureFlag } from '@app/lib/analytics/posthog';
import { useHotkeyInterceptor } from '@app/signal/hotkeyRoot';
import { globalSplitManager } from '@app/signal/splitLayout';
-import { InCallPanel } from '@channel/Call';
import { useCallContextOptional } from '@channel/Call/CallContext';
+import { InCallPanel } from '@channel/Call/InCallPanel';
import { useHasPaidAccess } from '@core/auth';
import { ContextMenuContent, MenuItem } from '@core/component/ContextMenu';
import { inboxIconProps } from '@core/component/inboxIcon';
diff --git a/js/app/packages/app/component/global-share-modal/GlobalShareModal.tsx b/js/app/packages/app/component/global-share-modal/GlobalShareModal.tsx
index a04c253a26..79a2fbbde3 100644
--- a/js/app/packages/app/component/global-share-modal/GlobalShareModal.tsx
+++ b/js/app/packages/app/component/global-share-modal/GlobalShareModal.tsx
@@ -1,11 +1,18 @@
import type { BlockAlias, BlockName } from '@core/block';
import { Permissions } from '@core/component/SharePermissions';
-import { ShareModal } from '@core/component/TopBar/ShareButton';
import { itemToBlockName } from '@core/constant/allBlocks';
import { createControlledOpenSignal } from '@core/util/createControlledOpenSignal';
import type { EntityData } from '@entity';
import type { ItemType } from '@service-storage/client';
-import { createSignal, Show } from 'solid-js';
+import { createSignal, lazy, Show, Suspense } from 'solid-js';
+
+// Lazy + open-gated: ShareModal pulls in ForwardToChannel and the markdown
+// editor stack, which would otherwise load with the initial bundle.
+const ShareModal = lazy(() =>
+ import('@core/component/TopBar/ShareButton').then((m) => ({
+ default: m.ShareModal,
+ }))
+);
type ShareableEntityType = 'document' | 'chat' | 'project';
@@ -76,16 +83,18 @@ export const GlobalShareModal = () => {
const entity = () => propsAccessor().entity;
return (
-
+
+
+
);
}}
diff --git a/js/app/packages/app/component/interactive-onboarding/InteractiveOnboardingModal.tsx b/js/app/packages/app/component/interactive-onboarding/InteractiveOnboardingModal.tsx
index 8b3e45a964..fa3fdc2c52 100644
--- a/js/app/packages/app/component/interactive-onboarding/InteractiveOnboardingModal.tsx
+++ b/js/app/packages/app/component/interactive-onboarding/InteractiveOnboardingModal.tsx
@@ -6,13 +6,25 @@ import ArrowRightIcon from '@phosphor/arrow-right.svg';
import CloseIcon from '@phosphor/x.svg';
import { useCompleteTutorialMutation } from '@queries/auth/tutorial';
import { Button, Dialog, Hotkey } from '@ui';
-import { type Component, createSignal, Match, Show, Switch } from 'solid-js';
+import {
+ type Component,
+ createSignal,
+ lazy,
+ Match,
+ Show,
+ Suspense,
+ Switch,
+} from 'solid-js';
import { Dynamic } from 'solid-js/web';
-import InteractiveOnboarding from './InteractiveOnboarding';
import { OnboardingProgress } from './OnboardingProgress';
import { useOnboarding } from './onboarding-context';
import type { LessonContentProps, LessonState } from './types';
+// Lazy: the onboarding lessons pull in the markdown editor stack (mentions,
+// emoji search, etc.), which would otherwise load with the initial bundle.
+// The chunk is fetched the first time the modal actually opens.
+const InteractiveOnboarding = lazy(() => import('./InteractiveOnboarding'));
+
interface InteractiveOnboardingModalProps {
open?: boolean;
defaultOpen?: boolean;
@@ -318,16 +330,19 @@ export function InteractiveOnboardingModal(
>
- setOpen(false)}
- ignoreTutorialCompleted
- isFirstTimeOnboarding={props.isFirstTimeOnboarding}
- >
- setOpen(false)}
- />
-
+ {/* Local boundary so the lazy chunk load can't re-trigger an outer Suspense. */}
+
+ setOpen(false)}
+ ignoreTutorialCompleted
+ isFirstTimeOnboarding={props.isFirstTimeOnboarding}
+ >
+ setOpen(false)}
+ />
+
+
diff --git a/js/app/packages/app/component/next-soup/soup-view/SoupEntityActionDrawer.tsx b/js/app/packages/app/component/next-soup/soup-view/SoupEntityActionDrawer.tsx
index 9f371188f1..cdaad7f721 100644
--- a/js/app/packages/app/component/next-soup/soup-view/SoupEntityActionDrawer.tsx
+++ b/js/app/packages/app/component/next-soup/soup-view/SoupEntityActionDrawer.tsx
@@ -1,6 +1,6 @@
import { MobileDrawer } from '@app/component/mobile/MobileDrawer';
import { useSplitPanelOrThrow } from '@app/component/split-layout/layoutUtils';
-import { getShareDrawerRecipientInput } from '@core/component/TopBar/ShareButton';
+import { getShareDrawerRecipientInput } from '@core/component/TopBar/shareDrawer';
import { triggerFocusInput } from '@core/directive/focusInput';
import { InlineEntity } from '@entity';
import { cn } from '@ui';
diff --git a/js/app/packages/app/component/next-soup/utils.ts b/js/app/packages/app/component/next-soup/utils.ts
index 82d63d3f43..6722dc88f0 100644
--- a/js/app/packages/app/component/next-soup/utils.ts
+++ b/js/app/packages/app/component/next-soup/utils.ts
@@ -9,7 +9,7 @@ import { URL_PARAMS as CHANNEL_PARAMS } from '@block-channel/constants';
import { getChannelParams } from '@block-channel/utils/link';
import { URL_PARAMS as EMAIL_PARAMS } from '@block-email/constants';
import { URL_PARAMS as MD_PARAMS } from '@block-md/constants';
-import { URL_PARAMS as PDF_PARAMS } from '@block-pdf/signal/location';
+import { URL_PARAMS as PDF_PARAMS } from '@block-pdf/constants';
import { fileTypeToBlockName } from '@core/constant/allBlocks';
import {
ENTITY_ID_DATA_ATTRIBUTE,
diff --git a/js/app/packages/app/component/split-layout/componentRegistry.tsx b/js/app/packages/app/component/split-layout/componentRegistry.tsx
index 15c3e689f6..cb51bd01e1 100644
--- a/js/app/packages/app/component/split-layout/componentRegistry.tsx
+++ b/js/app/packages/app/component/split-layout/componentRegistry.tsx
@@ -4,8 +4,6 @@ import { Home } from '@app/component/home';
import type { SetPredicatesInput } from '@app/component/next-soup/filters/filter-store/predicates-store';
import type { Query } from '@app/component/next-soup/filters/filter-store/types';
import { SoupView } from '@app/component/next-soup/soup-view/soup-view';
-import { ChannelCompose } from '@block-channel/component/Compose';
-import { ComposeTask } from '@block-md/component/ComposeTask';
import { useIsAuthenticated } from '@core/auth';
import { LoadingBlock } from '@core/component/LoadingBlock';
import {
@@ -17,8 +15,6 @@ import { useUserContext } from '@core/context/user';
import type { ViewId } from '@core/types/view';
import { useAutomationEntities } from '@queries/agent-schedule/entities';
import { type Component, type JSXElement, lazy, onMount, Show } from 'solid-js';
-import { EmailCompose } from '../../../block-email/component/compose/Compose';
-import { SettingsPanelComponentWrapper } from '../settings/Settings';
import type { SplitContent } from './layoutManager';
import { useSplitPanelOrThrow } from './layoutUtils';
@@ -311,6 +307,24 @@ registerComponent(
);
/** END - APP ROUTES */
+// Lazy: the compose surfaces pull in the markdown/channel editor stacks,
+// which would otherwise load with the initial bundle.
+const ChannelCompose = lazy(() =>
+ import('@block-channel/component/Compose').then((m) => ({
+ default: m.ChannelCompose,
+ }))
+);
+const EmailCompose = lazy(() =>
+ import('../../../block-email/component/compose/Compose').then((m) => ({
+ default: m.EmailCompose,
+ }))
+);
+const ComposeTask = lazy(() =>
+ import('@block-md/component/ComposeTask').then((m) => ({
+ default: m.ComposeTask,
+ }))
+);
+
registerComponent('loading', () => );
registerComponent('channel-compose', () => {
usePageViewTracking('channel-compose');
@@ -328,7 +342,14 @@ registerComponent(
'import-linear',
lazy(() => import('@app/component/import-linear/ImportLinear'))
);
-registerComponent('settings', () => );
+registerComponent(
+ 'settings',
+ lazy(() =>
+ import('../settings/Settings').then((m) => ({
+ default: m.SettingsPanelComponentWrapper,
+ }))
+ )
+);
if (LOCAL_ONLY) {
registerComponent(
diff --git a/js/app/packages/app/lib/analytics/analytics.ts b/js/app/packages/app/lib/analytics/analytics.ts
index 5b36b8c4a5..8303f29d88 100644
--- a/js/app/packages/app/lib/analytics/analytics.ts
+++ b/js/app/packages/app/lib/analytics/analytics.ts
@@ -10,7 +10,7 @@ import {
import { DEV_MODE_ENV, PROD_MODE_ENV } from '@core/constant/featureFlags';
import { isTouchDevice } from '@core/mobile/isTouchDevice';
import { getPlatform } from '@core/util/platform';
-import { PostHog } from 'posthog-js';
+import type { PostHog } from 'posthog-js';
import { match } from 'ts-pattern';
/**
@@ -136,8 +136,68 @@ const tryInitialize = (callback: VoidFunction) => {
}
};
+/**
+ * Synchronous stand-in for the PostHog instance while posthog-js loads.
+ * posthog-js is the only reason this module would pull a large SDK into the
+ * initial bundle, so it is imported lazily; until it resolves, fire-and-forget
+ * calls (capture/identify/reset/onFeatureFlags) are queued and replayed in
+ * order, and flag reads report "not loaded yet" — the same answers PostHog
+ * itself gives before its feature-flag fetch completes.
+ *
+ * Only the methods actually used in this codebase are implemented; the cast
+ * to PostHog keeps the public AnalyticsInterface type unchanged. If you need
+ * another PostHog method, add it here.
+ */
+const createDeferredPosthog = () => {
+ let real: PostHog | null = null;
+ const queue: Array<(ph: PostHog) => void> = [];
+ const run = (op: (ph: PostHog) => void) => {
+ if (real) op(real);
+ else queue.push(op);
+ };
+
+ const facade = {
+ capture: (...args: Parameters) => {
+ run((ph) => ph.capture(...args));
+ return undefined;
+ },
+ identify: (...args: Parameters) => {
+ run((ph) => ph.identify(...args));
+ },
+ reset: (...args: Parameters) => {
+ run((ph) => ph.reset(...args));
+ },
+ onFeatureFlags: (...args: Parameters) => {
+ let unsub: (() => void) | null = null;
+ let cancelled = false;
+ run((ph) => {
+ if (cancelled) return;
+ unsub = ph.onFeatureFlags(...args);
+ });
+ return () => {
+ cancelled = true;
+ unsub?.();
+ };
+ },
+ isFeatureEnabled: (...args: Parameters) =>
+ real?.isFeatureEnabled(...args),
+ getFeatureFlagResult: (
+ ...args: Parameters
+ ) => real?.getFeatureFlagResult(...args),
+ _isIdentified: () => real?._isIdentified() ?? false,
+ } as unknown as PostHog;
+
+ const attach = (ph: PostHog) => {
+ real = ph;
+ for (const op of queue) op(ph);
+ queue.length = 0;
+ };
+
+ return { facade, attach };
+};
+
const createAnalytics = () => {
- const posthog = new PostHog();
+ const { facade: posthog, attach: attachPosthog } = createDeferredPosthog();
const disabled = import.meta.env.DEV === true;
@@ -146,11 +206,20 @@ const createAnalytics = () => {
tryInitialize(initializeGoogleAnalytics);
tryInitialize(initializeMetaPixel);
- tryInitialize(() => initializePosthog(posthog));
};
initializeProviders();
+ // posthog-js loads lazily to keep it out of the initial bundle; queued
+ // events are replayed once it attaches.
+ void import('posthog-js').then(({ PostHog }) => {
+ const instance = new PostHog();
+ if (!disabled) {
+ tryInitialize(() => initializePosthog(instance));
+ }
+ attachPosthog(instance);
+ });
+
const sendEvent = (
provider: AnalyticsProvider,
event: EventName,
diff --git a/js/app/packages/app/vite.base.ts b/js/app/packages/app/vite.base.ts
index 51ddd080e5..e9e9db13fb 100644
--- a/js/app/packages/app/vite.base.ts
+++ b/js/app/packages/app/vite.base.ts
@@ -124,6 +124,10 @@ export const createAppViteConfig = (): UserConfigFn => {
input: {
app: resolve(__dirname, 'index.html'),
},
+ // No manualChunks: katex and pdfjs-dist are only reachable via
+ // dynamic import, so rollup already splits them. Forcing them into
+ // manual chunks made rollup hoist shared commonjs helpers there,
+ // which dragged pdfjs into the entry's static import graph.
output: NO_MINIFY
? {
// remove hashes from output paths
@@ -131,19 +135,11 @@ export const createAppViteConfig = (): UserConfigFn => {
entryFileNames: `assets/[name].js`,
chunkFileNames: `assets/[name].js`,
assetFileNames: `assets/[name].[ext]`,
- manualChunks: {
- katex: ['katex'],
- pdfjs: ['pdfjs-dist'],
- },
}
: {
format: 'es',
chunkFileNames: '[name]-[hash].js',
entryFileNames: '[name]-[hash].js',
- manualChunks: {
- katex: ['katex'],
- pdfjs: ['pdfjs-dist'],
- },
},
},
assetsInlineLimit: (filePath) => {
diff --git a/js/app/packages/block-automation/component/AutomationComposer.tsx b/js/app/packages/block-automation/component/AutomationComposer.tsx
index 18eccdbd12..a8dd77b8d7 100644
--- a/js/app/packages/block-automation/component/AutomationComposer.tsx
+++ b/js/app/packages/block-automation/component/AutomationComposer.tsx
@@ -1,6 +1,5 @@
import { useSplitLayout } from '@app/component/split-layout/layout';
import { toast } from '@core/component/Toast/Toast';
-import { createControlledOpenSignal } from '@core/util/createControlledOpenSignal';
import { useCreateScheduleMutation } from '@queries/agent-schedule/schedules';
import { debounce } from '@solid-primitives/scheduled';
import { Button, cn, Dialog, Surface } from '@ui';
@@ -19,6 +18,10 @@ import {
} from '../util/automationComposerStorage';
import { AutomationPromptEditor } from './AutomationPromptEditor';
import { AutomationTimePicker } from './AutomationTimePicker';
+import {
+ automationComposerOpen,
+ setAutomationComposerOpen,
+} from './automationComposerState';
import {
createEmptyDraft,
describeSchedule,
@@ -32,12 +35,10 @@ import {
} from './automationUtils';
import type { ScheduleDraft } from './types';
-/**
- * Open/close signal for the automation composer modal. Flip to `true` from
- * anywhere (e.g. launcher / unified-list create button) to pop the dialog.
- */
-export const [automationComposerOpen, setAutomationComposerOpen] =
- createControlledOpenSignal(false, { id: 'automation-composer' });
+export {
+ automationComposerOpen,
+ setAutomationComposerOpen,
+} from './automationComposerState';
/**
* Create-only automation composer modal. Mount once (see Layout.tsx) — the
diff --git a/js/app/packages/block-automation/component/automationComposerState.ts b/js/app/packages/block-automation/component/automationComposerState.ts
new file mode 100644
index 0000000000..f88b92e273
--- /dev/null
+++ b/js/app/packages/block-automation/component/automationComposerState.ts
@@ -0,0 +1,12 @@
+import { createControlledOpenSignal } from '@core/util/createControlledOpenSignal';
+
+/**
+ * Open/close signal for the automation composer modal. Flip to `true` from
+ * anywhere (e.g. launcher / unified-list create button) to pop the dialog.
+ *
+ * Lives apart from AutomationComposer.tsx so openers (launcher etc.) don't
+ * statically pull the composer UI (and the markdown editor stack behind it)
+ * into the initial bundle — Layout lazy-loads the component when this flips.
+ */
+export const [automationComposerOpen, setAutomationComposerOpen] =
+ createControlledOpenSignal(false, { id: 'automation-composer' });
diff --git a/js/app/packages/block-automation/component/index.ts b/js/app/packages/block-automation/component/index.ts
index 419db33ad9..4bf18dddc2 100644
--- a/js/app/packages/block-automation/component/index.ts
+++ b/js/app/packages/block-automation/component/index.ts
@@ -1,6 +1,6 @@
export { Automation } from './Automation';
+export { AutomationComposer } from './AutomationComposer';
export {
- AutomationComposer,
automationComposerOpen,
setAutomationComposerOpen,
-} from './AutomationComposer';
+} from './automationComposerState';
diff --git a/js/app/packages/block-automation/definition.ts b/js/app/packages/block-automation/definition.ts
index e02a8a40bd..9d1f611781 100644
--- a/js/app/packages/block-automation/definition.ts
+++ b/js/app/packages/block-automation/definition.ts
@@ -1,13 +1,14 @@
import { defineBlock, type ExtractLoadType, LoadErrors } from '@core/block';
import { ok } from 'neverthrow';
-
-import { Automation } from './component/Automation';
+import { lazy } from 'solid-js';
export const definition = defineBlock({
name: 'automation',
description: 'view and edit a single automation',
defaultFilename: 'Untitled automation',
- component: Automation,
+ component: lazy(() =>
+ import('./component/Automation').then((m) => ({ default: m.Automation }))
+ ),
accepted: {},
async load(source, intent) {
if (source.type === 'dss') {
diff --git a/js/app/packages/block-call/definition.ts b/js/app/packages/block-call/definition.ts
index 37269a7631..0320e99a64 100644
--- a/js/app/packages/block-call/definition.ts
+++ b/js/app/packages/block-call/definition.ts
@@ -1,14 +1,17 @@
import { defineBlock, type ExtractLoadType, LoadErrors } from '@core/block';
import { ENABLE_CALLS } from '@core/constant/featureFlags';
import { ok } from 'neverthrow';
-
-import { CallBlockAdapter } from './component/CallBlockAdapter';
+import { lazy } from 'solid-js';
export const definition = defineBlock({
name: 'call',
description: '',
defaultFilename: 'Call',
- component: CallBlockAdapter,
+ component: lazy(() =>
+ import('./component/CallBlockAdapter').then((m) => ({
+ default: m.CallBlockAdapter,
+ }))
+ ),
async load(source, _intent) {
if (!ENABLE_CALLS()) return LoadErrors.MISSING;
if (source.type === 'dss') {
diff --git a/js/app/packages/block-canvas/definition.ts b/js/app/packages/block-canvas/definition.ts
index 279c28dec0..44ab0d213f 100644
--- a/js/app/packages/block-canvas/definition.ts
+++ b/js/app/packages/block-canvas/definition.ts
@@ -9,13 +9,13 @@ import { fetchBinaryDocumentData } from '@queries/storage/binary-document';
import { fetchBinary } from '@service-storage/util/fetchBinary';
import { makeFileFromBlob } from '@service-storage/util/makeFileFromBlob';
import { err, ok } from 'neverthrow';
-import CanvasBlock from './component/Block';
+import { lazy } from 'solid-js';
import type { Canvas } from './model/CanvasModel';
export const definition = defineBlock({
name: 'canvas',
description: 'edit canvas',
- component: CanvasBlock,
+ component: lazy(() => import('./component/Block')),
accepted: {
canvas: 'application/x-macro-canvas',
},
diff --git a/js/app/packages/block-channel/definition.ts b/js/app/packages/block-channel/definition.ts
index 5ac015e3f3..ec9f0b0b14 100644
--- a/js/app/packages/block-channel/definition.ts
+++ b/js/app/packages/block-channel/definition.ts
@@ -1,12 +1,15 @@
import { defineBlock, type ExtractLoadType, LoadErrors } from '@core/block';
import { ok } from 'neverthrow';
-
-import { NewChannelBlockAdapter } from './component/NewChannelBlockAdapter';
+import { lazy } from 'solid-js';
export const definition = defineBlock({
name: 'channel',
description: '',
- component: NewChannelBlockAdapter,
+ component: lazy(() =>
+ import('./component/NewChannelBlockAdapter').then((m) => ({
+ default: m.NewChannelBlockAdapter,
+ }))
+ ),
liveTrackingEnabled: true,
async load(source, _intent) {
if (source.type === 'dss') {
diff --git a/js/app/packages/block-chat/definition.ts b/js/app/packages/block-chat/definition.ts
index d1c11e6590..3969357171 100644
--- a/js/app/packages/block-chat/definition.ts
+++ b/js/app/packages/block-chat/definition.ts
@@ -4,7 +4,7 @@ import { AgentModel } from '@service-cognition/generated/schemas';
import type { Entity } from '@service-cognition/generated/schemas/entity';
import type { DocumentMetadata } from '@service-storage/generated/schemas/documentMetadata';
import { ok } from 'neverthrow';
-import BlockChat from './component/Block';
+import { lazy } from 'solid-js';
export const DEFAULT_CHAT_NAME = 'New Chat';
@@ -14,7 +14,7 @@ export const definition = defineBlock({
name: 'chat',
description: '',
defaultFilename: DEFAULT_CHAT_NAME,
- component: BlockChat,
+ component: lazy(() => import('./component/Block')),
liveTrackingEnabled: true,
async load(source, intent) {
if (source.type === 'dss') {
diff --git a/js/app/packages/block-code/definition.ts b/js/app/packages/block-code/definition.ts
index ab591d8b31..b34e2d6fc5 100644
--- a/js/app/packages/block-code/definition.ts
+++ b/js/app/packages/block-code/definition.ts
@@ -6,14 +6,14 @@ import {
} from '@core/block';
import { storageServiceClient } from '@service-storage/client';
import { err, ok } from 'neverthrow';
-import BlockCode from './component/Block';
+import { lazy } from 'solid-js';
import { supportedExtensions } from './util/languageSupport';
export const definition = defineBlock({
name: 'code',
description: 'Edit code files with syntax highlighting and formatting',
aliases: [{ name: 'csv', defaultFileName: 'New CSV' }],
- component: BlockCode,
+ component: lazy(() => import('./component/Block')),
async load(source, intent) {
if (intent === 'preload') {
return ok({
diff --git a/js/app/packages/block-company/definition.ts b/js/app/packages/block-company/definition.ts
index 86baffeaa3..b922623452 100644
--- a/js/app/packages/block-company/definition.ts
+++ b/js/app/packages/block-company/definition.ts
@@ -1,12 +1,15 @@
import { defineBlock, type ExtractLoadType, LoadErrors } from '@core/block';
import { ok } from 'neverthrow';
-
-import { CompanyBlockAdapter } from './component/CompanyBlockAdapter';
+import { lazy } from 'solid-js';
export const definition = defineBlock({
name: 'company',
description: 'View a CRM company',
- component: CompanyBlockAdapter,
+ component: lazy(() =>
+ import('./component/CompanyBlockAdapter').then((m) => ({
+ default: m.CompanyBlockAdapter,
+ }))
+ ),
liveTrackingEnabled: false,
async load(source, _intent) {
if (source.type === 'dss') {
diff --git a/js/app/packages/block-contact/definition.ts b/js/app/packages/block-contact/definition.ts
index 91f32df703..adc3f1c864 100644
--- a/js/app/packages/block-contact/definition.ts
+++ b/js/app/packages/block-contact/definition.ts
@@ -1,12 +1,15 @@
import { defineBlock, type ExtractLoadType, LoadErrors } from '@core/block';
import { ok } from 'neverthrow';
-
-import { ContactBlockAdapter } from './component/ContactBlockAdapter';
+import { lazy } from 'solid-js';
export const definition = defineBlock({
name: 'contact',
description: 'View a CRM contact',
- component: ContactBlockAdapter,
+ component: lazy(() =>
+ import('./component/ContactBlockAdapter').then((m) => ({
+ default: m.ContactBlockAdapter,
+ }))
+ ),
liveTrackingEnabled: false,
async load(source, _intent) {
if (source.type === 'dss') {
diff --git a/js/app/packages/block-email/definition.ts b/js/app/packages/block-email/definition.ts
index 09555de1c1..3d4a31f909 100644
--- a/js/app/packages/block-email/definition.ts
+++ b/js/app/packages/block-email/definition.ts
@@ -1,12 +1,12 @@
import { defineBlock, type ExtractLoadType, LoadErrors } from '@core/block';
import { fetchAndCacheThread } from '@queries/email/thread';
import { ok } from 'neverthrow';
-import EmailBlock from './component/Block';
+import { lazy } from 'solid-js';
export const definition = defineBlock({
name: 'email',
description: 'View and manage email threads',
- component: EmailBlock,
+ component: lazy(() => import('./component/Block')),
liveTrackingEnabled: true,
syncServiceEnabled: false,
defaultFilename: '[No subject]',
diff --git a/js/app/packages/block-md/definition.ts b/js/app/packages/block-md/definition.ts
index 5321498413..4ae52451d0 100644
--- a/js/app/packages/block-md/definition.ts
+++ b/js/app/packages/block-md/definition.ts
@@ -8,9 +8,8 @@ import { ENABLE_MARKDOWN_LIVE_COLLABORATION } from '@core/constant/featureFlags'
import { waitForDocumentSyncServiceReady } from '@queries/storage/document-location';
import { storageServiceClient } from '@service-storage/client';
import { makeFileFromBlob } from '@service-storage/util/makeFileFromBlob';
-import { createSyncServiceSource } from '@service-sync/source';
import { err, ok } from 'neverthrow';
-import MarkdownBlock from './component/Block';
+import { lazy } from 'solid-js';
import type { MarkdownRewriteOutput } from './signal/rewriteSignal';
export const definition = defineBlock({
@@ -21,7 +20,7 @@ export const definition = defineBlock({
{ name: 'task', defaultFileName: 'New Task' },
{ name: 'snippet', defaultFileName: 'New Snippet' },
],
- component: MarkdownBlock,
+ component: lazy(() => import('./component/Block')),
accepted: {
md: 'text/markdown',
},
@@ -84,6 +83,7 @@ export const definition = defineBlock({
return LoadErrors.INVALID;
}
+ const { createSyncServiceSource } = await import('@service-sync/source');
const { source: syncSource, doInitialSync } = createSyncServiceSource(
source.id,
token
diff --git a/js/app/packages/block-pdf/constants.ts b/js/app/packages/block-pdf/constants.ts
new file mode 100644
index 0000000000..a2165c8958
--- /dev/null
+++ b/js/app/packages/block-pdf/constants.ts
@@ -0,0 +1,15 @@
+// URL params live here (not in signal/location.ts) so light consumers like
+// DocumentPreview and notification navigation don't pull the whole PDF viewer
+// into the initial bundle.
+export const URL_PARAMS = {
+ pageNumber: 'pdf_page_number',
+ yPos: 'pdf_page_y',
+ x: 'pdf_page_x',
+ width: 'pdf_width',
+ height: 'pdf_height',
+ annotationId: 'pdf_ann_id',
+ searchPage: 'pdf_search_page',
+ searchSnippet: 'pdf_search_snippet',
+ searchRawQuery: 'pdf_search_raw_query',
+ searchHighlightTerms: 'pdf_search_highlight_terms',
+} as const;
diff --git a/js/app/packages/block-pdf/definition.ts b/js/app/packages/block-pdf/definition.ts
index cdc10f96d9..ebfec6e271 100644
--- a/js/app/packages/block-pdf/definition.ts
+++ b/js/app/packages/block-pdf/definition.ts
@@ -9,7 +9,7 @@ import type { GetDocumentResponseDataViewLocation } from '@service-storage/gener
import { fetchBinary } from '@service-storage/util/fetchBinary';
import { err, ok } from 'neverthrow';
import type { PDFDocumentProxy } from 'pdfjs-dist/types/src/display/api';
-import BlockPdf from './component/Block';
+import { lazy } from 'solid-js';
import PdfJsWorker from './PdfViewer/pdfjs-worker?worker';
export const definition = defineBlock({
@@ -19,7 +19,7 @@ export const definition = defineBlock({
pdf: 'application/pdf',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
},
- component: BlockPdf,
+ component: lazy(() => import('./component/Block')),
liveTrackingEnabled: true,
async load(source, intent) {
if (source.type === 'dss') {
diff --git a/js/app/packages/block-pdf/signal/location.ts b/js/app/packages/block-pdf/signal/location.ts
index 05acf32104..eb7e33d1c9 100644
--- a/js/app/packages/block-pdf/signal/location.ts
+++ b/js/app/packages/block-pdf/signal/location.ts
@@ -1,3 +1,4 @@
+import { URL_PARAMS } from '@block-pdf/constants';
import type { PDFViewer } from '@block-pdf/PdfViewer';
import {
FindState,
@@ -30,18 +31,9 @@ import {
viewerReadySignal,
} from './pdfViewer';
-export const URL_PARAMS = {
- pageNumber: 'pdf_page_number',
- yPos: 'pdf_page_y',
- x: 'pdf_page_x',
- width: 'pdf_width',
- height: 'pdf_height',
- annotationId: 'pdf_ann_id',
- searchPage: 'pdf_search_page',
- searchSnippet: 'pdf_search_snippet',
- searchRawQuery: 'pdf_search_raw_query',
- searchHighlightTerms: 'pdf_search_highlight_terms',
-} as const;
+// Moved to ../constants so light consumers don't pull the viewer; re-exported
+// here for existing imports.
+export { URL_PARAMS };
export type LocationSearchParams = {
annotationId?: string;
diff --git a/js/app/packages/block-pdf/store/comments/freeComments.ts b/js/app/packages/block-pdf/store/comments/freeComments.ts
index 3e4813a90a..96fa042a37 100644
--- a/js/app/packages/block-pdf/store/comments/freeComments.ts
+++ b/js/app/packages/block-pdf/store/comments/freeComments.ts
@@ -12,18 +12,20 @@ import type {
PdfRoot,
ViewerCommentType,
} from '@block-pdf/type/comments';
-import type { IPlaceable, IThreadPlaceable } from '@block-pdf/type/placeables';
+import type { IThreadPlaceable } from '@block-pdf/type/placeables';
import { createBlockMemo } from '@core/block';
import { useUserId } from '@core/context/user';
+// Moved to ../../type/placeables so schema-only consumers (coParse via the
+// storage client) don't pull the viewer stores; re-exported for existing
+// imports.
+import { isThreadPlaceable } from '../../type/placeables';
import {
anchorsResource,
commentThreadsResource,
sortComments,
} from '../commentsResource';
-export function isThreadPlaceable(x: IPlaceable): x is IThreadPlaceable {
- return x.payloadType === 'thread';
-}
+export { isThreadPlaceable };
const getThreadPlaceablePos = (
placeable: IThreadPlaceable,
diff --git a/js/app/packages/block-pdf/type/coParse.ts b/js/app/packages/block-pdf/type/coParse.ts
index e8c0f1da2c..2dad3637c0 100644
--- a/js/app/packages/block-pdf/type/coParse.ts
+++ b/js/app/packages/block-pdf/type/coParse.ts
@@ -1,10 +1,10 @@
-import { isThreadPlaceable } from '@block-pdf/store/comments/freeComments';
import { v7 as uuid7 } from 'uuid';
import { z } from 'zod';
import {
type IPlaceable,
IPlaceableSchema,
IPlaceableServerSchema,
+ isThreadPlaceable,
} from '../type/placeables';
import { type IBookmark, IBookmarkSchema } from './Bookmark';
diff --git a/js/app/packages/block-pdf/type/placeables.ts b/js/app/packages/block-pdf/type/placeables.ts
index da7f93777d..ef5c004a04 100644
--- a/js/app/packages/block-pdf/type/placeables.ts
+++ b/js/app/packages/block-pdf/type/placeables.ts
@@ -202,3 +202,7 @@ export const IPlaceableSchema = PlaceableBaseSchema.and(IPayloadSchema).and(
z.object({ internalId: z.string() })
);
export const IPlaceableServerSchema = PlaceableBaseSchema.and(IPayloadSchema);
+
+export function isThreadPlaceable(x: IPlaceable): x is IThreadPlaceable {
+ return x.payloadType === 'thread';
+}
diff --git a/js/app/packages/block-unknown/definition.ts b/js/app/packages/block-unknown/definition.ts
index 9cef4a04f4..7e79f8d7e3 100644
--- a/js/app/packages/block-unknown/definition.ts
+++ b/js/app/packages/block-unknown/definition.ts
@@ -6,12 +6,12 @@ import {
} from '@core/block';
import { storageServiceClient } from '@service-storage/client';
import { err, ok } from 'neverthrow';
-import BlockUnknown from './component/Block';
+import { lazy } from 'solid-js';
export const definition = defineBlock({
name: 'unknown',
description: 'fallback block for unknown files types',
- component: BlockUnknown,
+ component: lazy(() => import('./component/Block')),
async load(source, intent) {
if (source.type === 'dss') {
const maybeDocument = await loadResult(
diff --git a/js/app/packages/block-video/definition.ts b/js/app/packages/block-video/definition.ts
index 578c64ecdb..b4a6cc3504 100644
--- a/js/app/packages/block-video/definition.ts
+++ b/js/app/packages/block-video/definition.ts
@@ -11,7 +11,7 @@ import type { DocumentMetadataFileType } from '@service-storage/generated/schema
import { getPresignedUrl } from '@service-storage/util/presignedUrl';
import { toast } from 'core/component/Toast/Toast';
import { err, ok } from 'neverthrow';
-import BlockVideo from './component/Block';
+import { lazy } from 'solid-js';
export const VIDEO_MIMES: Record<
NonNullable,
@@ -52,7 +52,7 @@ export const PLAYBACK_ENABLED_MIMES: Record =
export const definition = defineBlock({
name: 'video',
description: 'block for video file types',
- component: BlockVideo,
+ component: lazy(() => import('./component/Block')),
liveTrackingEnabled: false,
accepted: VIDEO_MIMES,
async load(source, intent) {
diff --git a/js/app/packages/channel/Call/CallAudioSink.tsx b/js/app/packages/channel/Call/CallAudioSink.tsx
index 9b2ff6de8b..a8b072333f 100644
--- a/js/app/packages/channel/Call/CallAudioSink.tsx
+++ b/js/app/packages/channel/Call/CallAudioSink.tsx
@@ -1,6 +1,6 @@
-import { Track } from 'livekit-client';
import { For, Show } from 'solid-js';
import { useCallContext } from './CallContext';
+import { LK_TRACK_SOURCE } from './livekit-loader';
import { TrackView } from './TrackView';
/**
@@ -25,7 +25,7 @@ export function CallAudioSink() {
.filter((p) => !p.isAgent)
.map((p) => ({
id: p.identity,
- track: p.getTrackPublication(Track.Source.Microphone)?.track,
+ track: p.getTrackPublication(LK_TRACK_SOURCE.Microphone)?.track,
}));
};
diff --git a/js/app/packages/channel/Call/CallContext.tsx b/js/app/packages/channel/Call/CallContext.tsx
index daa62839df..2adfd8e293 100644
--- a/js/app/packages/channel/Call/CallContext.tsx
+++ b/js/app/packages/channel/Call/CallContext.tsx
@@ -1,17 +1,13 @@
-import {
- isKrispNoiseFilterSupported,
- KrispNoiseFilter,
-} from '@livekit/krisp-noise-filter';
+import type { KrispNoiseFilter } from '@livekit/krisp-noise-filter';
import type { BackgroundProcessorWrapper } from '@livekit/track-processors';
import type { CallTokenResponse } from '@service-call/client';
import { makePersisted } from '@solid-primitives/storage';
-import {
- type AudioCaptureOptions,
+import type {
+ AudioCaptureOptions,
ConnectionState,
- type LocalTrack,
- type RemoteParticipant,
+ LocalTrack,
+ RemoteParticipant,
Room,
- Track,
} from 'livekit-client';
import {
createContext,
@@ -27,12 +23,30 @@ import {
type CallSessionDisconnectOptions,
createCallSessionController,
} from './CallSessionController';
-import { createLivekitJsCallController } from './LivekitJsCallController';
+import {
+ getKrisp,
+ getLivekit,
+ isKrispSupported,
+ LK_CONNECTION_STATE,
+ LK_TRACK_SOURCE,
+ loadKrisp,
+ loadLivekit,
+} from './livekit-loader';
import {
type NativeCallConnectionState,
useMaybeNativeCallState,
} from './native-call-state';
+// livekit-client and Krisp load lazily (see livekit-loader.ts); this module
+// only uses their types plus the LK_* enum mirrors so the app shell doesn't
+// pull both SDKs into the initial bundle.
+type LivekitJsCallController = ReturnType<
+ typeof import('./LivekitJsCallController')['createLivekitJsCallController']
+>;
+type LivekitJsCallControllerOptions = Parameters<
+ typeof import('./LivekitJsCallController')['createLivekitJsCallController']
+>[0];
+
export type CallParticipantInfo = {
identity: string;
isSpeaking: boolean;
@@ -109,7 +123,7 @@ function normalizeNoiseSuppressionMode(
function effectiveCaptureModeForPreferredMode(
mode: MicNoiseSuppressionMode
): MicNoiseSuppressionMode {
- if (mode === 'krisp' && !isKrispNoiseFilterSupported()) return 'browser';
+ if (mode === 'krisp' && !isKrispSupported()) return 'browser';
return mode;
}
@@ -160,16 +174,16 @@ function nativeAudioProcessingConstraints(
}
function getLocalMicTrack(r: Room): LocalTrack | undefined {
- return r.localParticipant.getTrackPublication(Track.Source.Microphone)
+ return r.localParticipant.getTrackPublication(LK_TRACK_SOURCE.Microphone)
?.track as LocalTrack | undefined;
}
function isActiveCallConnectionState(state: ConnectionState): boolean {
return (
- state === ConnectionState.Connecting ||
- state === ConnectionState.Connected ||
- state === ConnectionState.Reconnecting ||
- state === ConnectionState.SignalReconnecting
+ state === LK_CONNECTION_STATE.Connecting ||
+ state === LK_CONNECTION_STATE.Connected ||
+ state === LK_CONNECTION_STATE.Reconnecting ||
+ state === LK_CONNECTION_STATE.SignalReconnecting
);
}
@@ -197,11 +211,11 @@ async function applyNativeAudioProcessingToMicTrack(
// Swift exposes a transient `disconnecting` state that livekit-client lacks.
const NATIVE_TO_LIVEKIT_STATE = {
- disconnected: ConnectionState.Disconnected,
- connecting: ConnectionState.Connecting,
- connected: ConnectionState.Connected,
- reconnecting: ConnectionState.Reconnecting,
- disconnecting: ConnectionState.Disconnected,
+ disconnected: LK_CONNECTION_STATE.Disconnected,
+ connecting: LK_CONNECTION_STATE.Connecting,
+ connected: LK_CONNECTION_STATE.Connected,
+ reconnecting: LK_CONNECTION_STATE.Reconnecting,
+ disconnecting: LK_CONNECTION_STATE.Disconnected,
} satisfies Record;
type CallStoreState = {
@@ -233,7 +247,7 @@ type CallStoreState = {
};
const initialState: CallStoreState = {
- connectionState: ConnectionState.Disconnected,
+ connectionState: LK_CONNECTION_STATE.Disconnected,
activeChannelId: null,
activeCallId: null,
remoteParticipants: new Map(),
@@ -471,7 +485,7 @@ function createCallState() {
event,
preferredMode: persistedNoiseSuppressionMode(),
activeMode: store.noiseSuppressionMode,
- krispSupported: isKrispNoiseFilterSupported(),
+ krispSupported: isKrispSupported(),
hasMicTrack: !!micTrack,
micReadyState: mediaStreamTrack?.readyState,
hasProcessor: !!micTrack?.getProcessor(),
@@ -514,7 +528,17 @@ function createCallState() {
const micTrack = getLocalMicTrack(r);
if (!isLiveLocalTrack(micTrack)) return;
- if (preferredMode === 'browser' || !isKrispNoiseFilterSupported()) {
+ // Krisp loads lazily; resolve it before consulting isKrispSupported(). On
+ // load failure we fall through to the browser-native layer.
+ await loadKrisp().catch(() => null);
+ if (room() !== r || !isLiveLocalTrack(micTrack)) return;
+
+ const krispModule = getKrisp();
+ if (
+ preferredMode === 'browser' ||
+ !krispModule ||
+ !krispModule.isKrispNoiseFilterSupported()
+ ) {
setStore('noiseSuppressionMode', 'browser');
await detachKrispFromMicTrack(r);
if (room() !== r) return;
@@ -548,7 +572,7 @@ function createCallState() {
// `quality` is model size/CPU cost, not suppression strength. The default
// medium model avoids the CPU pressure/dropouts that made voices sound
// muddy on busy machines while still enabling Krisp when supported.
- const krisp = KrispNoiseFilter({ quality: 'medium' });
+ const krisp = krispModule.KrispNoiseFilter({ quality: 'medium' });
setKrispFilter(krisp);
await micTrack.setProcessor(krisp);
if (room() !== r || !isLiveLocalTrack(micTrack)) {
@@ -615,7 +639,9 @@ function createCallState() {
const effect = store.backgroundEffect;
if (effect.type === 'none') return true;
- const camPub = r.localParticipant.getTrackPublication(Track.Source.Camera);
+ const camPub = r.localParticipant.getTrackPublication(
+ LK_TRACK_SOURCE.Camera
+ );
const camTrack = camPub?.track as LocalTrack | undefined;
if (!isLiveLocalTrack(camTrack)) return true;
@@ -674,7 +700,7 @@ function createCallState() {
if (prev) {
try {
const camPub = r.localParticipant.getTrackPublication(
- Track.Source.Camera
+ LK_TRACK_SOURCE.Camera
);
if (camPub?.track) {
await (camPub.track as LocalTrack).stopProcessor();
@@ -724,6 +750,13 @@ function createCallState() {
// --- device enumeration ---
async function enumerateDevices() {
+ // Device lists are only consumed by in-call UI; if livekit-client hasn't
+ // been loaded yet (no call so far), there is nothing to populate. Each
+ // call connect re-enumerates via finishLocalMediaSetup.
+ const livekitModule = getLivekit();
+ if (!livekitModule) return;
+ const { Room } = livekitModule;
+
try {
const devices = await Room.getLocalDevices('audioinput');
setStore(
@@ -769,7 +802,7 @@ function createCallState() {
function trackActiveDevices(r: Room) {
const micPub = r.localParticipant.getTrackPublication(
- Track.Source.Microphone
+ LK_TRACK_SOURCE.Microphone
);
if (micPub?.track) {
const settings = (
@@ -796,7 +829,9 @@ function createCallState() {
// Only set the active video device when we can read it from a live track.
// When video is off we leave it null — guessing would show the wrong
// selection if the browser's default differs from the first enumerated device.
- const camPub = r.localParticipant.getTrackPublication(Track.Source.Camera);
+ const camPub = r.localParticipant.getTrackPublication(
+ LK_TRACK_SOURCE.Camera
+ );
if (camPub?.track) {
const settings = (
camPub.track as LocalTrack
@@ -904,9 +939,9 @@ function createCallState() {
const currentIsConnecting = () =>
(currentNativeCallSnapshot() === null &&
store.optimisticJoinChannelId !== null) ||
- currentConnectionState() === ConnectionState.Connecting ||
- currentConnectionState() === ConnectionState.Reconnecting ||
- currentConnectionState() === ConnectionState.SignalReconnecting;
+ currentConnectionState() === LK_CONNECTION_STATE.Connecting ||
+ currentConnectionState() === LK_CONNECTION_STATE.Reconnecting ||
+ currentConnectionState() === LK_CONNECTION_STATE.SignalReconnecting;
const currentJoinError = () =>
currentNativeCallSnapshot() ? null : store.joinError;
@@ -947,7 +982,26 @@ function createCallState() {
trackActiveDevices(targetRoom);
}
- const livekitJs = createLivekitJsCallController({
+ // The JS call controller (and with it livekit-client + Krisp) is created on
+ // first connect. Krisp is loaded alongside so the microphone capture
+ // options consulted during Room construction see the same isKrispSupported()
+ // answer as they did when both SDKs were imported eagerly.
+ let livekitJs: LivekitJsCallController | null = null;
+ let livekitJsPromise: Promise | null = null;
+
+ function getLivekitJsController(): Promise {
+ livekitJsPromise ??= Promise.all([
+ import('./LivekitJsCallController'),
+ loadLivekit(),
+ loadKrisp().catch(() => null),
+ ]).then(([mod]) => {
+ livekitJs = mod.createLivekitJsCallController(livekitJsOptions);
+ return livekitJs;
+ });
+ return livekitJsPromise;
+ }
+
+ const livekitJsOptions: LivekitJsCallControllerOptions = {
room,
setRoom,
state: () => ({
@@ -987,12 +1041,20 @@ function createCallState() {
bumpTrackVersion,
bumpSpeakerVersion: () => setStore('speakerVersion', (v) => v + 1),
setScreenSharing: (value) => setStore('isScreenSharing', value),
- });
+ };
const callSession = createCallSessionController({
nativeCall,
- jsConnect: livekitJs.connect,
- jsDisconnect: livekitJs.disconnect,
+ jsConnect: async (tokenResponse) => {
+ const controller = await getLivekitJsController();
+ return controller.connect(tokenResponse);
+ },
+ jsDisconnect: async () => {
+ // Never connected in this session — there is no JS room to tear down.
+ if (!livekitJsPromise) return;
+ const controller = await getLivekitJsController();
+ return controller.disconnect();
+ },
});
async function toggleAudio() {
@@ -1042,7 +1104,7 @@ function createCallState() {
);
// Read the actual device the browser chose so the dropdown is accurate
const camPub = r.localParticipant.getTrackPublication(
- Track.Source.Camera
+ LK_TRACK_SOURCE.Camera
);
if (camPub?.track) {
const settings = (
@@ -1151,7 +1213,7 @@ function createCallState() {
// --- cleanup ---
const handleBeforeUnload = () => {
- livekitJs.disconnectBeforeUnload();
+ livekitJs?.disconnectBeforeUnload();
};
window.addEventListener('beforeunload', handleBeforeUnload);
@@ -1161,7 +1223,7 @@ function createCallState() {
'devicechange',
handleDeviceChange
);
- livekitJs.dispose();
+ livekitJs?.dispose();
});
// --- public API ---
diff --git a/js/app/packages/channel/Call/callkit-drawer-theme.ts b/js/app/packages/channel/Call/callkit-drawer-theme.ts
index b7f5d9ba9f..e16ed5a1fe 100644
--- a/js/app/packages/channel/Call/callkit-drawer-theme.ts
+++ b/js/app/packages/channel/Call/callkit-drawer-theme.ts
@@ -2,7 +2,6 @@ import { ENABLE_CALLKIT } from '@core/constant/featureFlags';
import { isPlatform, isTauri } from '@core/util/platform';
import { invoke } from '@tauri-apps/api/core';
import { themeReactive } from '@theme/signals/themeReactive';
-import Color from 'colorjs.io';
import { type Accessor, createMemo } from 'solid-js';
export type RgbaColor = {
@@ -144,12 +143,14 @@ function oklchToRgba(
alpha?: number
): RgbaColor {
try {
- const srgb = new Color('oklch', [token.l, token.c, token.h]).to('srgb');
+ const [red, green, blue] = oklchToLinearSrgb(token).map((channel) =>
+ clampColorChannel(srgbGamma(channel))
+ );
return {
- red: clampColorChannel(srgb.coords[0]),
- green: clampColorChannel(srgb.coords[1]),
- blue: clampColorChannel(srgb.coords[2]),
- alpha: clampColorChannel(alpha ?? srgb.alpha ?? 1),
+ red,
+ green,
+ blue,
+ alpha: clampColorChannel(alpha ?? 1),
};
} catch (err) {
console.error('[callkit] failed to resolve theme color', {
@@ -160,6 +161,39 @@ function oklchToRgba(
}
}
+// OKLCH -> linear sRGB via OKLab (Björn Ottosson's reference matrices — the
+// same math colorjs.io applies; inlined here to keep colorjs.io out of the
+// initial bundle). Out-of-gamut channels are clamped, matching the previous
+// clampColorChannel behavior.
+function oklchToLinearSrgb(token: {
+ l: number;
+ c: number;
+ h: number;
+}): [number, number, number] {
+ const hRad = (token.h * Math.PI) / 180;
+ const labL = token.l;
+ const labA = token.c * Math.cos(hRad);
+ const labB = token.c * Math.sin(hRad);
+
+ const l = (labL + 0.3963377774 * labA + 0.2158037573 * labB) ** 3;
+ const m = (labL - 0.1055613458 * labA - 0.0638541728 * labB) ** 3;
+ const s = (labL - 0.0894841775 * labA - 1.291485548 * labB) ** 3;
+
+ return [
+ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
+ -1.2684380046 * l + 2.6097574011 * m - 0.7034186147 * s,
+ -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
+ ];
+}
+
+function srgbGamma(channel: number): number {
+ const sign = channel < 0 ? -1 : 1;
+ const abs = Math.abs(channel);
+ return abs > 0.0031308
+ ? sign * (1.055 * abs ** (1 / 2.4) - 0.055)
+ : 12.92 * channel;
+}
+
function callKitDrawerThemeKey(theme: CallKitDrawerTheme): string {
return [
colorKeyPart(theme.drawerBackground),
diff --git a/js/app/packages/channel/Call/livekit-loader.ts b/js/app/packages/channel/Call/livekit-loader.ts
new file mode 100644
index 0000000000..be4f2dfe6f
--- /dev/null
+++ b/js/app/packages/channel/Call/livekit-loader.ts
@@ -0,0 +1,99 @@
+/**
+ * @file Lazy loaders for livekit-client and the Krisp noise filter.
+ *
+ * livekit-client + @livekit/krisp-noise-filter are by far the heaviest
+ * dependencies reachable from the app shell (the Krisp package inlines its
+ * audio worklet + model). CallProvider mounts app-wide, so everything it
+ * touches loads with the initial bundle — these loaders keep both SDKs out of
+ * that path; they are fetched when a call actually starts (or a media device
+ * changes while the call stack is live).
+ */
+import type {
+ ConnectionState,
+ DisconnectReason,
+ RoomEvent,
+ Track,
+} from 'livekit-client';
+
+type LivekitModule = typeof import('livekit-client');
+type KrispModule = typeof import('@livekit/krisp-noise-filter');
+
+let livekit: LivekitModule | null = null;
+let livekitPromise: Promise | null = null;
+let krisp: KrispModule | null = null;
+let krispPromise: Promise | null = null;
+
+export function loadLivekit(): Promise {
+ livekitPromise ??= import('livekit-client').then((m) => {
+ livekit = m;
+ return m;
+ });
+ return livekitPromise;
+}
+
+/** Sync access to livekit-client; null until loadLivekit() resolves. */
+export function getLivekit(): LivekitModule | null {
+ return livekit;
+}
+
+export function loadKrisp(): Promise {
+ krispPromise ??= import('@livekit/krisp-noise-filter').then((m) => {
+ krisp = m;
+ return m;
+ });
+ return krispPromise;
+}
+
+/** Sync access to the Krisp module; null until loadKrisp() resolves. */
+export function getKrisp(): KrispModule | null {
+ return krisp;
+}
+
+/**
+ * True when the Krisp module is loaded and reports browser support. Call
+ * sites that can await should loadKrisp() first; until it resolves this
+ * reports false and callers fall back to browser-native noise suppression.
+ */
+export function isKrispSupported(): boolean {
+ return krisp?.isKrispNoiseFilterSupported() ?? false;
+}
+
+// Mirrors of livekit-client's string enums so call state can be tracked
+// without loading the SDK. Each value is asserted to the corresponding enum
+// member's type, so a livekit-client upgrade that changes a value fails to
+// compile here.
+export const LK_CONNECTION_STATE = {
+ Disconnected: 'disconnected' as ConnectionState.Disconnected,
+ Connecting: 'connecting' as ConnectionState.Connecting,
+ Connected: 'connected' as ConnectionState.Connected,
+ Reconnecting: 'reconnecting' as ConnectionState.Reconnecting,
+ SignalReconnecting:
+ 'signalReconnecting' as ConnectionState.SignalReconnecting,
+} as const;
+
+export const LK_TRACK_SOURCE = {
+ Camera: 'camera' as Track.Source.Camera,
+ Microphone: 'microphone' as Track.Source.Microphone,
+ ScreenShare: 'screen_share' as Track.Source.ScreenShare,
+} as const;
+
+export const LK_ROOM_EVENT = {
+ Disconnected: 'disconnected' as RoomEvent.Disconnected,
+} as const;
+
+// DisconnectReason comes from @livekit/protocol — protobuf wire values,
+// stable by definition. The satisfies clause pins each literal to the enum
+// member's type so a renumbering fails to compile.
+export const LK_DISCONNECT_REASON = {
+ CLIENT_INITIATED: 1,
+ DUPLICATE_IDENTITY: 2,
+ PARTICIPANT_REMOVED: 4,
+ ROOM_DELETED: 5,
+ ROOM_CLOSED: 10,
+} as const satisfies {
+ CLIENT_INITIATED: DisconnectReason.CLIENT_INITIATED;
+ DUPLICATE_IDENTITY: DisconnectReason.DUPLICATE_IDENTITY;
+ PARTICIPANT_REMOVED: DisconnectReason.PARTICIPANT_REMOVED;
+ ROOM_DELETED: DisconnectReason.ROOM_DELETED;
+ ROOM_CLOSED: DisconnectReason.ROOM_CLOSED;
+};
diff --git a/js/app/packages/channel/Call/use-call.ts b/js/app/packages/channel/Call/use-call.ts
index 7ef268ce0d..d9cc734aa7 100644
--- a/js/app/packages/channel/Call/use-call.ts
+++ b/js/app/packages/channel/Call/use-call.ts
@@ -6,9 +6,10 @@ import {
} from '@queries/call/call';
import { callServiceClient } from '@service-call/client';
import { useMutation } from '@tanstack/solid-query';
-import { DisconnectReason, RoomEvent } from 'livekit-client';
+import type { DisconnectReason } from 'livekit-client';
import { createEffect, createSignal, onCleanup } from 'solid-js';
import { useCallContext } from './CallContext';
+import { LK_DISCONNECT_REASON, LK_ROOM_EVENT } from './livekit-loader';
import { registerCallKitCallEndedHandler } from './use-callkit';
type UseCallOptions = {
@@ -33,11 +34,11 @@ type ActiveJoinAttempt = {
function shouldAutoRejoin(reason?: DisconnectReason) {
switch (reason) {
- case DisconnectReason.CLIENT_INITIATED:
- case DisconnectReason.DUPLICATE_IDENTITY:
- case DisconnectReason.PARTICIPANT_REMOVED:
- case DisconnectReason.ROOM_DELETED:
- case DisconnectReason.ROOM_CLOSED:
+ case LK_DISCONNECT_REASON.CLIENT_INITIATED:
+ case LK_DISCONNECT_REASON.DUPLICATE_IDENTITY:
+ case LK_DISCONNECT_REASON.PARTICIPANT_REMOVED:
+ case LK_DISCONNECT_REASON.ROOM_DELETED:
+ case LK_DISCONNECT_REASON.ROOM_CLOSED:
return false;
default:
return true;
@@ -114,9 +115,9 @@ export function useCall(channelId: () => string, options?: UseCallOptions) {
cleanupDisconnectListener = null;
scheduleAutoRejoin(reason);
};
- room.on(RoomEvent.Disconnected, handleDisconnect);
+ room.on(LK_ROOM_EVENT.Disconnected, handleDisconnect);
cleanupDisconnectListener = () =>
- room.off(RoomEvent.Disconnected, handleDisconnect);
+ room.off(LK_ROOM_EVENT.Disconnected, handleDisconnect);
}
// If the call is already active for this channel (e.g. the user navigated
diff --git a/js/app/packages/core/component/DocumentPreview.tsx b/js/app/packages/core/component/DocumentPreview.tsx
index 5b55f2f163..bb8e049111 100644
--- a/js/app/packages/core/component/DocumentPreview.tsx
+++ b/js/app/packages/core/component/DocumentPreview.tsx
@@ -2,7 +2,7 @@ import { URL_PARAMS as URL_PARAMS_CANVAS } from '@block-canvas/constants';
import { URL_PARAMS as CHANNEL_PARAMS } from '@block-channel/constants';
import { useOpenChatForAttachment } from '@block-chat/client';
import { URL_PARAMS as URL_PARAMS_MD } from '@block-md/constants';
-import { URL_PARAMS as URL_PARAMS_PDF } from '@block-pdf/signal/location';
+import { URL_PARAMS as URL_PARAMS_PDF } from '@block-pdf/constants';
import {
type BlockAlias,
type BlockName,
@@ -11,7 +11,16 @@ import {
} from '@core/block';
import { EntityIcon } from '@core/component/EntityIcon';
import { isBlockNameWithLocation } from '@core/component/LexicalMarkdown/component/core/BlockLink';
-import { StaticMarkdown } from '@core/component/LexicalMarkdown/component/core/StaticMarkdown';
+
+// Lazy: StaticMarkdown pulls the markdown parsing stack (@lexical/markdown,
+// transformers, prism); DocumentPreview loads with the initial bundle via the
+// DocumentCard decorator.
+const StaticMarkdown = lazy(() =>
+ import('@core/component/LexicalMarkdown/component/core/StaticMarkdown').then(
+ (m) => ({ default: m.StaticMarkdown })
+ )
+);
+
import { channelTheme } from '@core/component/LexicalMarkdown/theme';
import { toast } from '@core/component/Toast/Toast';
import { UserIcon as UserIconComponent } from '@core/component/UserIcon';
@@ -56,6 +65,7 @@ import {
createMemo,
createSignal,
For,
+ lazy,
Match,
onCleanup,
Show,
@@ -837,11 +847,13 @@ export function PopupPreview(props: {
{(context) => (
)}
diff --git a/js/app/packages/core/component/LexicalMarkdown/builder/MarkdownShell.tsx b/js/app/packages/core/component/LexicalMarkdown/builder/MarkdownShell.tsx
index 95445006b0..b499f4ad4a 100644
--- a/js/app/packages/core/component/LexicalMarkdown/builder/MarkdownShell.tsx
+++ b/js/app/packages/core/component/LexicalMarkdown/builder/MarkdownShell.tsx
@@ -14,14 +14,24 @@ import {
type Component,
createEffect,
createSignal,
+ lazy,
on,
onCleanup,
Show,
+ Suspense,
} from 'solid-js';
import { DecoratorRenderer } from '../component/core/DecoratorRenderer';
import { NodeAccessoryRenderer } from '../component/core/NodeAccessoryRenderer';
import { ActionMenu } from '../component/menu/ActionsMenu';
-import { EmojiMenu } from '../component/menu/EmojiMenu';
+
+// Lazy: the emoji menu pulls fuse.js plus the emoji datasets; it only mounts
+// when the ':' menu is actually opened.
+const EmojiMenu = lazy(() =>
+ import('../component/menu/EmojiMenu').then((m) => ({
+ default: m.EmojiMenu,
+ }))
+);
+
import { FloatingLinkMenu } from '../component/menu/FloatingLinkMenu';
import { MentionsMenu } from '../component/menu/MentionsMenu';
import { SnippetsMenu } from '../component/menu/SnippetsMenu';
@@ -287,12 +297,14 @@ export const MarkdownShell: Component<
{/* Emoji Menu */}
{(menu) => (
-
+
+
+
)}
diff --git a/js/app/packages/core/component/LexicalMarkdown/citationsUtils.ts b/js/app/packages/core/component/LexicalMarkdown/citationsUtils.ts
index a2946ba4e5..28780e26be 100644
--- a/js/app/packages/core/component/LexicalMarkdown/citationsUtils.ts
+++ b/js/app/packages/core/component/LexicalMarkdown/citationsUtils.ts
@@ -1,5 +1,5 @@
import { URL_PARAMS as MD_URL_PARAMS } from '@block-md/constants';
-import { URL_PARAMS as PDF_URL_PARAMS } from '@block-pdf/signal/location';
+import { URL_PARAMS as PDF_URL_PARAMS } from '@block-pdf/constants';
import { itemToBlockName } from '@core/constant/allBlocks';
import { useChannelsContext } from '@core/context/channels';
diff --git a/js/app/packages/core/component/LexicalMarkdown/component/decorator/ContactMention.tsx b/js/app/packages/core/component/LexicalMarkdown/component/decorator/ContactMention.tsx
index 1d1547a7c6..d74df28e07 100644
--- a/js/app/packages/core/component/LexicalMarkdown/component/decorator/ContactMention.tsx
+++ b/js/app/packages/core/component/LexicalMarkdown/component/decorator/ContactMention.tsx
@@ -8,9 +8,9 @@ import { COMMAND_PRIORITY_NORMAL, KEY_ENTER_COMMAND } from 'lexical';
import { createSignal, Show, useContext } from 'solid-js';
import { Portal } from 'solid-js/web';
import { useSplitLayout } from '../../../../../app/component/split-layout/layout';
-import { LexicalWrapperContext } from '../../context/LexicalWrapperContext';
+import { LexicalWrapperContext } from '../../context/wrapperContext';
import { floatWithElement } from '../../directive/floatWithElement';
-import { autoRegister } from '../../plugins';
+import { autoRegister } from '../../plugins/shared/utils';
import { MentionTooltip } from './MentionTooltip';
false && floatWithElement;
diff --git a/js/app/packages/core/component/LexicalMarkdown/component/decorator/DateMention.tsx b/js/app/packages/core/component/LexicalMarkdown/component/decorator/DateMention.tsx
index f61299ea5d..5b185d5249 100644
--- a/js/app/packages/core/component/LexicalMarkdown/component/decorator/DateMention.tsx
+++ b/js/app/packages/core/component/LexicalMarkdown/component/decorator/DateMention.tsx
@@ -1,7 +1,7 @@
import { DatePicker } from '@core/component/DatePicker';
import { formatDate } from '@core/util/dateParser';
import type { DateMentionDecoratorProps } from '@lexical-core';
-import { $isDateMentionNode } from '@lexical-core';
+import { $isDateMentionNode } from '@lexical-core/nodes/DateMentionNode';
import ClockIcon from '@phosphor/clock.svg';
import { differenceInCalendarDays } from 'date-fns';
import {
@@ -11,9 +11,9 @@ import {
} from 'lexical';
import { createMemo, createSignal, Show, useContext } from 'solid-js';
import { Portal } from 'solid-js/web';
-import { LexicalWrapperContext } from '../../context/LexicalWrapperContext';
+import { LexicalWrapperContext } from '../../context/wrapperContext';
import { floatWithElement } from '../../directive/floatWithElement';
-import { autoRegister } from '../../plugins';
+import { autoRegister } from '../../plugins/shared/utils';
import { MentionTooltip } from './MentionTooltip';
false && floatWithElement;
diff --git a/js/app/packages/core/component/LexicalMarkdown/component/decorator/DiffInsert.tsx b/js/app/packages/core/component/LexicalMarkdown/component/decorator/DiffInsert.tsx
index 2cca21e6fe..03f9b9efbe 100644
--- a/js/app/packages/core/component/LexicalMarkdown/component/decorator/DiffInsert.tsx
+++ b/js/app/packages/core/component/LexicalMarkdown/component/decorator/DiffInsert.tsx
@@ -1,10 +1,16 @@
import { useUserId } from '@core/context/user';
import type { DiffInsertDecoratorProps } from '@lexical-core';
-import { $isDiffNode } from '@lexical-core';
+import type { DiffNode } from '@lexical-core/nodes/DiffNode';
import { $getNodeByKey } from 'lexical';
-import { createMemo, Show, useContext } from 'solid-js';
-import { LexicalWrapperContext } from '../../context/LexicalWrapperContext';
-import { StaticMarkdown } from '../core/StaticMarkdown';
+import { createMemo, lazy, Show, Suspense, useContext } from 'solid-js';
+import { LexicalWrapperContext } from '../../context/wrapperContext';
+
+// Lazy: StaticMarkdown pulls the markdown parsing stack (@lexical/markdown,
+// transformers, prism). Decorators load with the initial bundle via
+// initializeLexical(), so the heavy renderer is fetched on first diff render.
+const StaticMarkdown = lazy(() =>
+ import('../core/StaticMarkdown').then((m) => ({ default: m.StaticMarkdown }))
+);
export function DiffInsert(props: DiffInsertDecoratorProps) {
const wrapper = useContext(LexicalWrapperContext);
@@ -19,16 +25,20 @@ export function DiffInsert(props: DiffInsertDecoratorProps) {
if (!node) return false;
const parent = node.getParent();
- if (!parent || !$isDiffNode(parent)) return false;
+ // getType() check instead of $isDiffNode: importing the class would
+ // pull the markdown transformer stack into the initial bundle.
+ if (!parent || parent.getType() !== 'diff') return false;
- return parent.getUserId() === userId();
+ return (parent as DiffNode).getUserId() === userId();
});
});
return (
-
+
+
+
);
diff --git a/js/app/packages/core/component/LexicalMarkdown/component/decorator/DocumentCard.tsx b/js/app/packages/core/component/LexicalMarkdown/component/decorator/DocumentCard.tsx
index 46911318ba..1246028c92 100644
--- a/js/app/packages/core/component/LexicalMarkdown/component/decorator/DocumentCard.tsx
+++ b/js/app/packages/core/component/LexicalMarkdown/component/decorator/DocumentCard.tsx
@@ -11,17 +11,17 @@ import { ENABLE_BLOCK_IN_BLOCK } from '@core/constant/featureFlags';
import { canNestBlock, createBlockInstance } from '@core/orchestrator';
import { blockElementSignal } from '@core/signal/blockElement';
import { matches } from '@core/util/match';
+import { HISTORY_MERGE_TAG } from '@lexical-core/constants';
import {
$convertCardToMention,
- $getId,
$isDocumentCardNode,
DEFAULT_PREVIEW_BOX,
type DocumentCardDecoratorProps,
- HISTORY_MERGE_TAG,
type PreviewBox,
setDocumentCardPreviewComponent,
unsetDocumentCardPreviewCache,
-} from '@lexical-core';
+} from '@lexical-core/nodes/DocumentCardNode';
+import { $getId } from '@lexical-core/plugins/nodeIdPlugin';
import Minimize from '@phosphor/arrows-in.svg';
import Clipboard from '@phosphor/clipboard.svg';
import ClockIcon from '@phosphor/clock.svg';
@@ -48,6 +48,7 @@ import {
createMemo,
createRoot,
createSignal,
+ lazy,
Match,
onCleanup,
runWithOwner,
@@ -59,12 +60,20 @@ import {
import { Dynamic } from 'solid-js/web';
import { formatDate } from '../../../../util/date';
import { TaskPropertiesPreview } from '../../../DocumentPreview';
-import { LexicalWrapperContext } from '../../context/LexicalWrapperContext';
+import { LexicalWrapperContext } from '../../context/wrapperContext';
import { floatWithElement } from '../../directive/floatWithElement';
-import { UPDATE_DOCUMENT_NAME_COMMAND } from '../../plugins';
+import { UPDATE_DOCUMENT_NAME_COMMAND } from '../../plugins/commands';
import { dispatchInternalLayoutShift } from '../../plugins/shared/utils';
import { BlockLink } from '../core/BlockLink';
-import { ChannelMessageThreadCard } from './ChannelMessageThreadCard';
+
+// Lazy: the thread card pulls the channel Message UI tree (reactions, emoji
+// search, ...); decorators load with the initial bundle via
+// initializeLexical(), so the card chunk is fetched on first render instead.
+const ChannelMessageThreadCard = lazy(() =>
+ import('./ChannelMessageThreadCard').then((m) => ({
+ default: m.ChannelMessageThreadCard,
+ }))
+);
false && floatWithElement;
@@ -240,10 +249,12 @@ function DocumentCardInner(props: DocumentCardDecoratorProps) {
} else {
getElement = () => (
-
+
+
+
);
}
diff --git a/js/app/packages/core/component/LexicalMarkdown/component/decorator/DocumentMention.tsx b/js/app/packages/core/component/LexicalMarkdown/component/decorator/DocumentMention.tsx
index b1c35c6c76..1ea820234d 100644
--- a/js/app/packages/core/component/LexicalMarkdown/component/decorator/DocumentMention.tsx
+++ b/js/app/packages/core/component/LexicalMarkdown/component/decorator/DocumentMention.tsx
@@ -27,10 +27,12 @@ import { openInNewSplitForMention } from '@core/util/openInNewSplit';
import { useSplitNavigationHandler } from '@core/util/useSplitNavigationHandler';
import {
$convertMentionToCard,
- $isDocumentMentionNode,
DocumentCardNode,
+} from '@lexical-core/nodes/DocumentCardNode';
+import {
+ $isDocumentMentionNode,
type DocumentMentionDecoratorProps,
-} from '@lexical-core';
+} from '@lexical-core/nodes/DocumentMentionNode';
import EyeSlashDuo from '@phosphor/eye-slash.svg';
import TrashSimple from '@phosphor/trash-simple.svg';
import { PropertyValueIcon } from '@property/component/propertyValue/PropertyValueIcon';
@@ -61,8 +63,9 @@ import {
Switch,
useContext,
} from 'solid-js';
-import { LexicalWrapperContext } from '../../context/LexicalWrapperContext';
-import { autoRegister, UPDATE_DOCUMENT_NAME_COMMAND } from '../../plugins';
+import { LexicalWrapperContext } from '../../context/wrapperContext';
+import { UPDATE_DOCUMENT_NAME_COMMAND } from '../../plugins/commands';
+import { autoRegister } from '../../plugins/shared/utils';
import { openDocument } from '../core/BlockLink';
import { MentionTooltip } from './MentionTooltip';
diff --git a/js/app/packages/core/component/LexicalMarkdown/component/decorator/Equation.tsx b/js/app/packages/core/component/LexicalMarkdown/component/decorator/Equation.tsx
index 1651fc29b9..33be20081a 100644
--- a/js/app/packages/core/component/LexicalMarkdown/component/decorator/Equation.tsx
+++ b/js/app/packages/core/component/LexicalMarkdown/component/decorator/Equation.tsx
@@ -1,8 +1,8 @@
import { cn } from '@ui';
import type { NodeKey } from 'lexical';
import { createEffect, createSignal, onMount, useContext } from 'solid-js';
-import { LexicalWrapperContext } from '../../context/LexicalWrapperContext';
-import { TRY_UPDATE_EQUATION_COMMAND } from '../../plugins';
+import { LexicalWrapperContext } from '../../context/wrapperContext';
+import { TRY_UPDATE_EQUATION_COMMAND } from '../../plugins/commands';
// Lazy load katex - will be loaded on first render
let katexModule: typeof import('katex') | null = null;
diff --git a/js/app/packages/core/component/LexicalMarkdown/component/decorator/GroupMention.tsx b/js/app/packages/core/component/LexicalMarkdown/component/decorator/GroupMention.tsx
index e64a4ce882..ee20ba7ca3 100644
--- a/js/app/packages/core/component/LexicalMarkdown/component/decorator/GroupMention.tsx
+++ b/js/app/packages/core/component/LexicalMarkdown/component/decorator/GroupMention.tsx
@@ -1,7 +1,7 @@
import type { GroupMentionDecoratorProps } from '@lexical-core';
import { cn } from '@ui';
import { useContext } from 'solid-js';
-import { LexicalWrapperContext } from '../../context/LexicalWrapperContext';
+import { LexicalWrapperContext } from '../../context/wrapperContext';
export function GroupMention(props: GroupMentionDecoratorProps) {
const lexicalWrapper = useContext(LexicalWrapperContext);
diff --git a/js/app/packages/core/component/LexicalMarkdown/component/decorator/HorizontalRule.tsx b/js/app/packages/core/component/LexicalMarkdown/component/decorator/HorizontalRule.tsx
index 8960ce8c5c..9db0e0c7b7 100644
--- a/js/app/packages/core/component/LexicalMarkdown/component/decorator/HorizontalRule.tsx
+++ b/js/app/packages/core/component/LexicalMarkdown/component/decorator/HorizontalRule.tsx
@@ -9,7 +9,7 @@ import {
$setSelection,
} from 'lexical';
import { createSignal, useContext } from 'solid-js';
-import { LexicalWrapperContext } from '../../context/LexicalWrapperContext';
+import { LexicalWrapperContext } from '../../context/wrapperContext';
export function HorizontalRule(props: HorizontalRuleDecoratorProps) {
const lexicalWrapper = useContext(LexicalWrapperContext);
diff --git a/js/app/packages/core/component/LexicalMarkdown/component/decorator/MarkdownImage.tsx b/js/app/packages/core/component/LexicalMarkdown/component/decorator/MarkdownImage.tsx
index 210b3bf406..ed0ab75537 100644
--- a/js/app/packages/core/component/LexicalMarkdown/component/decorator/MarkdownImage.tsx
+++ b/js/app/packages/core/component/LexicalMarkdown/component/decorator/MarkdownImage.tsx
@@ -7,7 +7,10 @@ import { debouncedDependent } from '@core/util/debounce';
import { Dialog } from '@kobalte/core/dialog';
import { mergeRegister } from '@lexical/utils';
-import { $isImageNode, type ImageDecoratorProps } from '@lexical-core';
+import {
+ $isImageNode,
+ type ImageDecoratorProps,
+} from '@lexical-core/nodes/ImageNode';
import { calculateEffectiveDimensions } from '@lexical-core/utils/media';
import ImageIcon from '@phosphor/image-broken.svg';
import LoadingSpinner from '@phosphor/spinner.svg';
@@ -31,7 +34,7 @@ import {
Show,
useContext,
} from 'solid-js';
-import { LexicalWrapperContext } from '../../context/LexicalWrapperContext';
+import { LexicalWrapperContext } from '../../context/wrapperContext';
import {
$upgradeDSSMediaUrl,
getMediaUrl,
@@ -40,7 +43,7 @@ import {
UPLOAD_MEDIA_FAILURE_COMMAND,
UPLOAD_MEDIA_START_COMMAND,
UPLOAD_MEDIA_SUCCESS_COMMAND,
-} from '../../plugins/media';
+} from '../../plugins/commands';
import { MediaButtons } from './MediaButtons';
import { ResizeHandle } from './ResizeHandle';
diff --git a/js/app/packages/core/component/LexicalMarkdown/component/decorator/MarkdownVideo.tsx b/js/app/packages/core/component/LexicalMarkdown/component/decorator/MarkdownVideo.tsx
index 1c5c89d89e..58d9a30808 100644
--- a/js/app/packages/core/component/LexicalMarkdown/component/decorator/MarkdownVideo.tsx
+++ b/js/app/packages/core/component/LexicalMarkdown/component/decorator/MarkdownVideo.tsx
@@ -3,7 +3,10 @@ import { debouncedDependent } from '@core/util/debounce';
import { Dialog } from '@kobalte/core/dialog';
import { mergeRegister } from '@lexical/utils';
-import { $isVideoNode, type VideoDecoratorProps } from '@lexical-core';
+import {
+ $isVideoNode,
+ type VideoDecoratorProps,
+} from '@lexical-core/nodes/VideoNode';
import VideoIcon from '@phosphor/file-video.svg';
import LoadingSpinner from '@phosphor/spinner.svg';
import XIcon from '@phosphor/x.svg';
@@ -25,7 +28,7 @@ import {
Show,
useContext,
} from 'solid-js';
-import { LexicalWrapperContext } from '../../context/LexicalWrapperContext';
+import { LexicalWrapperContext } from '../../context/wrapperContext';
import {
$upgradeDSSMediaUrl,
getMediaUrl,
@@ -34,7 +37,7 @@ import {
UPLOAD_MEDIA_FAILURE_COMMAND,
UPLOAD_MEDIA_START_COMMAND,
UPLOAD_MEDIA_SUCCESS_COMMAND,
-} from '../../plugins';
+} from '../../plugins/commands';
import { MediaButtons } from './MediaButtons';
import { ResizeHandle } from './ResizeHandle';
diff --git a/js/app/packages/core/component/LexicalMarkdown/component/decorator/Snapshot.tsx b/js/app/packages/core/component/LexicalMarkdown/component/decorator/Snapshot.tsx
index 1b55b3a331..ceecc84d51 100644
--- a/js/app/packages/core/component/LexicalMarkdown/component/decorator/Snapshot.tsx
+++ b/js/app/packages/core/component/LexicalMarkdown/component/decorator/Snapshot.tsx
@@ -9,7 +9,10 @@ import { verifyBlockName } from '@core/constant/allBlocks';
import { matches } from '@core/util/match';
import { openInNewSplitForMention } from '@core/util/openInNewSplit';
import { useSplitNavigationHandler } from '@core/util/useSplitNavigationHandler';
-import { $isSnapshotNode, type SnapshotDecoratorProps } from '@lexical-core';
+import {
+ $isSnapshotNode,
+ type SnapshotDecoratorProps,
+} from '@lexical-core/nodes/SnapshotNode';
import EyeSlashDuo from '@phosphor/eye-slash.svg';
import LoadingSpinner from '@phosphor/spinner.svg';
import TrashSimple from '@phosphor/trash-simple.svg';
@@ -23,8 +26,8 @@ import {
} from 'lexical';
import type { JSX } from 'solid-js';
import { createMemo, Suspense, useContext } from 'solid-js';
-import { LexicalWrapperContext } from '../../context/LexicalWrapperContext';
-import { autoRegister } from '../../plugins';
+import { LexicalWrapperContext } from '../../context/wrapperContext';
+import { autoRegister } from '../../plugins/shared/utils';
import { openDocument } from '../core/BlockLink';
import { MentionTooltip } from './MentionTooltip';
diff --git a/js/app/packages/core/component/LexicalMarkdown/component/decorator/ThemeMention.tsx b/js/app/packages/core/component/LexicalMarkdown/component/decorator/ThemeMention.tsx
index eec1021861..252e489cb9 100644
--- a/js/app/packages/core/component/LexicalMarkdown/component/decorator/ThemeMention.tsx
+++ b/js/app/packages/core/component/LexicalMarkdown/component/decorator/ThemeMention.tsx
@@ -6,7 +6,7 @@ import { applyTheme } from '@theme/utils/themeUtils';
import { isThemeV2 } from '@theme/utils/themeValidation';
import { cn } from '@ui';
import { useContext } from 'solid-js';
-import { LexicalWrapperContext } from '../../context/LexicalWrapperContext';
+import { LexicalWrapperContext } from '../../context/wrapperContext';
export function ThemeMention(props: ThemeMentionDecoratorProps) {
const { openSettings } = useSettingsState();
diff --git a/js/app/packages/core/component/LexicalMarkdown/component/decorator/UnknownMention.tsx b/js/app/packages/core/component/LexicalMarkdown/component/decorator/UnknownMention.tsx
index 5af34dd261..6b2e1a1679 100644
--- a/js/app/packages/core/component/LexicalMarkdown/component/decorator/UnknownMention.tsx
+++ b/js/app/packages/core/component/LexicalMarkdown/component/decorator/UnknownMention.tsx
@@ -1,7 +1,7 @@
import type { UnknownMentionDecoratorProps } from '@lexical-core';
import Fallback from '@phosphor/placeholder.svg';
import { useContext } from 'solid-js';
-import { LexicalWrapperContext } from '../../context/LexicalWrapperContext';
+import { LexicalWrapperContext } from '../../context/wrapperContext';
export function UnknownMention(props: UnknownMentionDecoratorProps) {
const lexicalWrapper = useContext(LexicalWrapperContext);
diff --git a/js/app/packages/core/component/LexicalMarkdown/component/decorator/UserMention.tsx b/js/app/packages/core/component/LexicalMarkdown/component/decorator/UserMention.tsx
index 8440b090f6..2d369db975 100644
--- a/js/app/packages/core/component/LexicalMarkdown/component/decorator/UserMention.tsx
+++ b/js/app/packages/core/component/LexicalMarkdown/component/decorator/UserMention.tsx
@@ -4,7 +4,7 @@ import { macroIdToEmail, tryMacroId, useDisplayName } from '@core/user';
import type { UserMentionDecoratorProps } from '@lexical-core';
import { cn } from '@ui';
import { createMemo, createSignal, useContext } from 'solid-js';
-import { LexicalWrapperContext } from '../../context/LexicalWrapperContext';
+import { LexicalWrapperContext } from '../../context/wrapperContext';
export function UserMention(props: UserMentionDecoratorProps) {
const lexicalWrapper = useContext(LexicalWrapperContext);
diff --git a/js/app/packages/core/component/LexicalMarkdown/component/dom-factory/diff-factory.tsx b/js/app/packages/core/component/LexicalMarkdown/component/dom-factory/diff-factory.tsx
index b1865235be..beba7bc651 100644
--- a/js/app/packages/core/component/LexicalMarkdown/component/dom-factory/diff-factory.tsx
+++ b/js/app/packages/core/component/LexicalMarkdown/component/dom-factory/diff-factory.tsx
@@ -1,5 +1,6 @@
import { useUserId } from '@core/context/user';
-import { DiffNode, setDOMFactory } from '@lexical-core';
+import { setDOMFactory } from '@lexical-core/domFactoryRegistry';
+import { DiffNode } from '@lexical-core/nodes/DiffNode';
import CheckIcon from '@phosphor/check.svg';
import XIcon from '@phosphor/x.svg';
import { render } from 'solid-js/web';
diff --git a/js/app/packages/core/component/LexicalMarkdown/context/LexicalWrapperContext.tsx b/js/app/packages/core/component/LexicalMarkdown/context/LexicalWrapperContext.tsx
index 3f313bd37c..5b58e9e2e8 100644
--- a/js/app/packages/core/component/LexicalMarkdown/context/LexicalWrapperContext.tsx
+++ b/js/app/packages/core/component/LexicalMarkdown/context/LexicalWrapperContext.tsx
@@ -10,21 +10,18 @@ import {
SupportedNodeTypes,
} from '@lexical-core';
import type { NodeKey } from 'lexical';
-import {
- createEditor,
- type EditorThemeClasses,
- type LexicalEditor,
-} from 'lexical';
-import { createContext } from 'solid-js';
-import type { Store } from 'solid-js/store';
+import { createEditor, type EditorThemeClasses } from 'lexical';
import {
createPluginManager,
insertTextPlugin,
nodeTransformPlugin,
- type PluginManager,
- type SelectionData,
} from '../plugins';
import { theme as baseTheme } from '../theme';
+import type {
+ LexicalWrapper,
+ LexicalWrapperBase,
+ LexicalWrapperWithMapping,
+} from './wrapperContext';
type LexicalWrapperProps = {
type: EditorType;
@@ -34,24 +31,16 @@ type LexicalWrapperProps = {
theme?: EditorThemeClasses;
};
-export type LexicalWrapperBase = {
- type: EditorType;
- plugins: PluginManager;
- editor: LexicalEditor;
- cleanup: () => void;
- isInteractable: () => boolean;
- selection?: Store;
- /** When true, decorator components should skip backend fetches (e.g. preview API). */
- skipPreviewFetch?: boolean;
-};
-
-export type LexicalWrapperWithMapping = LexicalWrapperBase & {
- mapping: NodeIdMappings;
-};
-
-export type LexicalWrapper = LexicalWrapperBase | LexicalWrapperWithMapping;
-
-export const LexicalWrapperContext = createContext();
+// The context and its types live in ./wrapperContext so light consumers
+// (decorator components) don't pull in the plugin machinery; re-exported here
+// for existing imports.
+export {
+ isWrapperWithIds,
+ type LexicalWrapper,
+ type LexicalWrapperBase,
+ LexicalWrapperContext,
+ type LexicalWrapperWithMapping,
+} from './wrapperContext';
// Simple increasing id to differentiate multiple editors on page.
let _id = 0;
@@ -134,14 +123,6 @@ export function createLexicalWrapper({
};
}
-export function isWrapperWithIds(
- wrapper: LexicalWrapper | undefined
-): wrapper is LexicalWrapperWithMapping {
- return Boolean(
- wrapper && 'mapping' in wrapper && wrapper['mapping'] !== undefined
- );
-}
-
function createMapping(): NodeIdMappings {
const idToNodeKeyMap: Map = new Map();
const nodeKeyToIdMap: Map = new Map();
diff --git a/js/app/packages/core/component/LexicalMarkdown/context/wrapperContext.ts b/js/app/packages/core/component/LexicalMarkdown/context/wrapperContext.ts
new file mode 100644
index 0000000000..f2dee74c95
--- /dev/null
+++ b/js/app/packages/core/component/LexicalMarkdown/context/wrapperContext.ts
@@ -0,0 +1,37 @@
+/**
+ * @file The wrapper context and its types, separated from createLexicalWrapper
+ * so consumers that only need the context (e.g. decorator components, which
+ * load with the initial bundle) don't pull in the plugin/editor machinery.
+ */
+import type { EditorType, NodeIdMappings } from '@lexical-core';
+import type { LexicalEditor } from 'lexical';
+import { createContext } from 'solid-js';
+import type { Store } from 'solid-js/store';
+import type { PluginManager, SelectionData } from '../plugins';
+
+export type LexicalWrapperBase = {
+ type: EditorType;
+ plugins: PluginManager;
+ editor: LexicalEditor;
+ cleanup: () => void;
+ isInteractable: () => boolean;
+ selection?: Store;
+ /** When true, decorator components should skip backend fetches (e.g. preview API). */
+ skipPreviewFetch?: boolean;
+};
+
+export type LexicalWrapperWithMapping = LexicalWrapperBase & {
+ mapping: NodeIdMappings;
+};
+
+export type LexicalWrapper = LexicalWrapperBase | LexicalWrapperWithMapping;
+
+export const LexicalWrapperContext = createContext();
+
+export function isWrapperWithIds(
+ wrapper: LexicalWrapper | undefined
+): wrapper is LexicalWrapperWithMapping {
+ return Boolean(
+ wrapper && 'mapping' in wrapper && wrapper['mapping'] !== undefined
+ );
+}
diff --git a/js/app/packages/core/component/LexicalMarkdown/init.ts b/js/app/packages/core/component/LexicalMarkdown/init.ts
index 86a590dba0..d0ad956494 100644
--- a/js/app/packages/core/component/LexicalMarkdown/init.ts
+++ b/js/app/packages/core/component/LexicalMarkdown/init.ts
@@ -1,23 +1,24 @@
-import {
- AwaitNode,
- ContactMentionNode,
- DateMentionNode,
- DiffInsertNode,
- DocumentCardNode,
- DocumentMentionNode,
- EquationNode,
- GroupMentionNode,
- HorizontalRuleNode,
- HtmlRenderNode,
- ImageNode,
- SnapshotNode,
- ThemeMentionNode,
- UnknownMentionNode,
- UserMentionNode,
- VideoNode,
- WatermarkNode,
-} from '@lexical-core';
+// Import node classes from their individual modules (not the @lexical-core
+// barrel) so this boot-path module doesn't drag node-list/@lexical/table,
+// CustomCodeNode/prismjs and the transformers into the initial bundle.
import { clearDecorators, setDecorator } from '@lexical-core/decoratorRegistry';
+import { AwaitNode } from '@lexical-core/nodes/AwaitNode';
+import { ContactMentionNode } from '@lexical-core/nodes/ContactMentionNode';
+import { DateMentionNode } from '@lexical-core/nodes/DateMentionNode';
+import { DiffInsertNode } from '@lexical-core/nodes/DiffInsertNode';
+import { DocumentCardNode } from '@lexical-core/nodes/DocumentCardNode';
+import { DocumentMentionNode } from '@lexical-core/nodes/DocumentMentionNode';
+import { EquationNode } from '@lexical-core/nodes/EquationNode';
+import { GroupMentionNode } from '@lexical-core/nodes/GroupMentionNode';
+import { HorizontalRuleNode } from '@lexical-core/nodes/HorizontalRuleNode';
+import { HtmlRenderNode } from '@lexical-core/nodes/HtmlRenderNode';
+import { ImageNode } from '@lexical-core/nodes/ImageNode';
+import { SnapshotNode } from '@lexical-core/nodes/SnapshotNode';
+import { ThemeMentionNode } from '@lexical-core/nodes/ThemeMentionNode';
+import { UnknownMentionNode } from '@lexical-core/nodes/UnknownMentionNode';
+import { UserMentionNode } from '@lexical-core/nodes/UserMentionNode';
+import { VideoNode } from '@lexical-core/nodes/VideoNode';
+import { WatermarkNode } from '@lexical-core/nodes/WatermarkNode';
import { Await } from './component/decorator/Await';
import { ContactMention } from './component/decorator/ContactMention';
import { DateMention } from './component/decorator/DateMention';
diff --git a/js/app/packages/core/component/LexicalMarkdown/plugins/commands.ts b/js/app/packages/core/component/LexicalMarkdown/plugins/commands.ts
new file mode 100644
index 0000000000..37b746e0b3
--- /dev/null
+++ b/js/app/packages/core/component/LexicalMarkdown/plugins/commands.ts
@@ -0,0 +1,84 @@
+import { staticFileIdEndpoint } from '@core/constant/servers';
+import type { FetchError } from '@core/service';
+import type { ResultError } from '@core/util/result';
+import { $isImageNode } from '@lexical-core/nodes/ImageNode';
+import type { MediaType } from '@lexical-core/nodes/MediaNode';
+import { $isVideoNode } from '@lexical-core/nodes/VideoNode';
+import { fetchBinaryDocumentData } from '@queries/storage/binary-document';
+import {
+ $getNodeByKey,
+ createCommand,
+ type LexicalCommand,
+ type NodeKey,
+} from 'lexical';
+import { ok, type Result } from 'neverthrow';
+
+// Commands (and small helpers) shared between plugins and decorator
+// components. Decorators are registered at boot via initializeLexical(), so
+// anything they import loads with the initial bundle — this module must stay
+// free of heavy imports (plugin trees, editor registration code, viewers).
+// The owning plugins re-export these for their other consumers.
+
+export const UPDATE_DOCUMENT_NAME_COMMAND: LexicalCommand<
+ Record
+> = createCommand('UPDATE_DOCUMENT_NAME_COMMAND');
+
+export const TRY_UPDATE_EQUATION_COMMAND: LexicalCommand =
+ createCommand('TRY_UPDATE_EQUATION_COMMAND');
+
+export const UPLOAD_MEDIA_SUCCESS_COMMAND: LexicalCommand<
+ [NodeKey, string, MediaType]
+> = createCommand('UPLOAD_MEDIA_SUCCESS_COMMAND');
+
+export const UPLOAD_MEDIA_FAILURE_COMMAND: LexicalCommand<
+ [NodeKey, MediaType]
+> = createCommand('UPLOAD_MEDIA_FAILURE_COMMAND');
+
+export const UPLOAD_MEDIA_START_COMMAND: LexicalCommand<[NodeKey, MediaType]> =
+ createCommand('UPLOAD_MEDIA_START_COMMAND');
+
+export const ON_MEDIA_COMPONENT_MOUNT_COMMAND: LexicalCommand<
+ [NodeKey, MediaType]
+> = createCommand('ON_MEDIA_COMPONENT_MOUNT_COMMAND');
+
+export const UPDATE_MEDIA_SIZE_COMMAND: LexicalCommand<
+ [NodeKey, { width: number; height: number }, MediaType]
+> = createCommand('UPDATE_MEDIA_SIZE_COMMAND');
+
+/**
+ * Get the URL for media based on its source type.
+ */
+export async function getMediaUrl(src: {
+ type: string;
+ id: string;
+ url: string;
+}): Promise[]>> {
+ if (src.type === 'local' || src.type === 'url') return ok(src.url);
+ if (src.type === 'sfs') {
+ const url = staticFileIdEndpoint(src.id);
+ return ok(url);
+ }
+ if (src.type === 'dss') {
+ return (await fetchBinaryDocumentData(src.id)).map((res) => res.blobUrl);
+ }
+ console.warn('Get media url failed for src:', src);
+ return ok('');
+}
+
+/**
+ * Upgrade DSS media URL after document checks.
+ */
+export function $upgradeDSSMediaUrl(
+ key: NodeKey,
+ url: string,
+ mediaType: MediaType
+) {
+ const node = $getNodeByKey(key);
+ if (!node) return;
+
+ if (mediaType === 'image' && $isImageNode(node)) {
+ node.setUrl(url, false);
+ } else if (mediaType === 'video' && $isVideoNode(node)) {
+ node.setUrl(url, false);
+ }
+}
diff --git a/js/app/packages/core/component/LexicalMarkdown/plugins/document-metadata/documentMetadataPlugin.ts b/js/app/packages/core/component/LexicalMarkdown/plugins/document-metadata/documentMetadataPlugin.ts
index 3231c98cd2..dcd8c57951 100644
--- a/js/app/packages/core/component/LexicalMarkdown/plugins/document-metadata/documentMetadataPlugin.ts
+++ b/js/app/packages/core/component/LexicalMarkdown/plugins/document-metadata/documentMetadataPlugin.ts
@@ -1,5 +1,5 @@
import { mergeRegister } from '@lexical/utils';
-import { HISTORY_MERGE_TAG } from '@lexical-core';
+import { HISTORY_MERGE_TAG } from '@lexical-core/constants';
import {
$getRoot,
$getState,
diff --git a/js/app/packages/core/component/LexicalMarkdown/plugins/katex/katexPlugin.ts b/js/app/packages/core/component/LexicalMarkdown/plugins/katex/katexPlugin.ts
index 8a5f4535ba..8a1d0428fc 100644
--- a/js/app/packages/core/component/LexicalMarkdown/plugins/katex/katexPlugin.ts
+++ b/js/app/packages/core/component/LexicalMarkdown/plugins/katex/katexPlugin.ts
@@ -11,6 +11,7 @@ import {
type LexicalCommand,
type LexicalEditor,
} from 'lexical';
+import { TRY_UPDATE_EQUATION_COMMAND } from '../commands';
// Type definitions
type InsertCommandPayload = {
@@ -28,8 +29,10 @@ type KatexPluginProps = {
onCreateEquation?: () => void;
};
-export const TRY_UPDATE_EQUATION_COMMAND: LexicalCommand =
- createCommand('TRY_UPDATE_EQUATION_COMMAND');
+// Defined in ../commands so decorator components can import it without
+// pulling this plugin module into the boot bundle; re-exported for existing
+// consumers.
+export { TRY_UPDATE_EQUATION_COMMAND };
export const UPDATE_EQUATION_COMMAND: LexicalCommand =
createCommand('UPDATE_EQUATION_COMMAND');
diff --git a/js/app/packages/core/component/LexicalMarkdown/plugins/media/mediaPlugin.ts b/js/app/packages/core/component/LexicalMarkdown/plugins/media/mediaPlugin.ts
index 4bfa34e266..6745b4d680 100644
--- a/js/app/packages/core/component/LexicalMarkdown/plugins/media/mediaPlugin.ts
+++ b/js/app/packages/core/component/LexicalMarkdown/plugins/media/mediaPlugin.ts
@@ -1,8 +1,6 @@
import { blockNameToFileExtensionSet } from '@core/constant/allBlocks';
import { staticFileIdEndpoint } from '@core/constant/servers';
import { heicConversionService } from '@core/heic/service';
-import type { FetchError } from '@core/service';
-import type { ResultError } from '@core/util/result';
import {
createStaticUploadFile,
createUploadFile,
@@ -14,14 +12,15 @@ import {
import { mergeRegister } from '@lexical/utils';
import {
$createImageNode,
- $createVideoNode,
$isImageNode,
- $isVideoNode,
type ImageNode,
- type MediaType,
+} from '@lexical-core/nodes/ImageNode';
+import type { MediaType } from '@lexical-core/nodes/MediaNode';
+import {
+ $createVideoNode,
+ $isVideoNode,
type VideoNode,
-} from '@lexical-core';
-import { fetchBinaryDocumentData } from '@queries/storage/binary-document';
+} from '@lexical-core/nodes/VideoNode';
import { fileExtension } from '@service-storage/util/filename';
import {
$createNodeSelection,
@@ -40,8 +39,16 @@ import {
type LexicalEditor,
type NodeKey,
} from 'lexical';
-import { ok, type Result } from 'neverthrow';
import { $insertNodesAndSplitList } from '../../utils';
+import {
+ $upgradeDSSMediaUrl,
+ getMediaUrl,
+ ON_MEDIA_COMPONENT_MOUNT_COMMAND,
+ UPDATE_MEDIA_SIZE_COMMAND,
+ UPLOAD_MEDIA_FAILURE_COMMAND,
+ UPLOAD_MEDIA_START_COMMAND,
+ UPLOAD_MEDIA_SUCCESS_COMMAND,
+} from '../commands';
import { mapRegisterDelete } from '../shared';
type DSSMedia = {
@@ -76,24 +83,18 @@ type MediaCreationPayload = Exclude & {
export const INSERT_MEDIA_COMMAND: LexicalCommand =
createCommand('INSERT_MEDIA_COMMAND');
-export const UPLOAD_MEDIA_SUCCESS_COMMAND: LexicalCommand<
- [NodeKey, string, MediaType]
-> = createCommand('UPLOAD_MEDIA_SUCCESS_COMMAND');
-
-export const UPLOAD_MEDIA_FAILURE_COMMAND: LexicalCommand<
- [NodeKey, MediaType]
-> = createCommand('UPLOAD_MEDIA_FAILURE_COMMAND');
-
-export const UPLOAD_MEDIA_START_COMMAND: LexicalCommand<[NodeKey, MediaType]> =
- createCommand('UPLOAD_MEDIA_START_COMMAND');
-
-export const ON_MEDIA_COMPONENT_MOUNT_COMMAND: LexicalCommand<
- [NodeKey, MediaType]
-> = createCommand('ON_MEDIA_COMPONENT_MOUNT_COMMAND');
-
-export const UPDATE_MEDIA_SIZE_COMMAND: LexicalCommand<
- [NodeKey, { width: number; height: number }, MediaType]
-> = createCommand('UPDATE_MEDIA_SIZE_COMMAND');
+// Defined in ../commands so decorator components (MarkdownImage/Video) can
+// import them without pulling this plugin module into the boot bundle;
+// re-exported for existing consumers.
+export {
+ $upgradeDSSMediaUrl,
+ getMediaUrl,
+ ON_MEDIA_COMPONENT_MOUNT_COMMAND,
+ UPDATE_MEDIA_SIZE_COMMAND,
+ UPLOAD_MEDIA_FAILURE_COMMAND,
+ UPLOAD_MEDIA_START_COMMAND,
+ UPLOAD_MEDIA_SUCCESS_COMMAND,
+};
export const TRY_INSERT_MEDIA_UPLOAD_COMMAND: LexicalCommand<
MediaType | 'all'
@@ -122,26 +123,6 @@ export async function addMediaFromFile(
return { success: true };
}
-/**
- * Get the URL for media based on its source type.
- */
-export async function getMediaUrl(src: {
- type: string;
- id: string;
- url: string;
-}): Promise[]>> {
- if (src.type === 'local' || src.type === 'url') return ok(src.url);
- if (src.type === 'sfs') {
- const url = staticFileIdEndpoint(src.id);
- return ok(url);
- }
- if (src.type === 'dss') {
- return (await fetchBinaryDocumentData(src.id)).map((res) => res.blobUrl);
- }
- console.warn('Get media url failed for src:', src);
- return ok('');
-}
-
/**
* Generate a unique key for a file to prevent duplicate uploads.
*/
@@ -263,24 +244,6 @@ function $safeInsertMediaNode(node: ImageNode | VideoNode) {
$insertNodesAndSplitList([node]);
}
-/**
- * Upgrade DSS media URL after document checks.
- */
-export function $upgradeDSSMediaUrl(
- key: NodeKey,
- url: string,
- mediaType: MediaType
-) {
- const node = $getNodeByKey(key);
- if (!node) return;
-
- if (mediaType === 'image' && $isImageNode(node)) {
- node.setUrl(url, false);
- } else if (mediaType === 'video' && $isVideoNode(node)) {
- node.setUrl(url, false);
- }
-}
-
function registerMediaPlugin(editor: LexicalEditor) {
let consumeNextDelete = false;
const cachedUploads = new Map();
diff --git a/js/app/packages/core/component/LexicalMarkdown/plugins/mentions/mentionsPlugin.ts b/js/app/packages/core/component/LexicalMarkdown/plugins/mentions/mentionsPlugin.ts
index 8a055c4909..b4bc95aab2 100644
--- a/js/app/packages/core/component/LexicalMarkdown/plugins/mentions/mentionsPlugin.ts
+++ b/js/app/packages/core/component/LexicalMarkdown/plugins/mentions/mentionsPlugin.ts
@@ -65,6 +65,7 @@ import type { Setter } from 'solid-js';
import { match } from 'ts-pattern';
import type { MenuOperations } from '../../shared/inlineMenu';
import { $collapseSelection, $traverseNodes, nodeByKey } from '../../utils';
+import { UPDATE_DOCUMENT_NAME_COMMAND } from '../commands';
import { mapRegisterDelete } from '../shared';
export const INSERT_DOCUMENT_MENTION_COMMAND: LexicalCommand =
@@ -95,9 +96,10 @@ export const REMOVE_INLINE_SEARCH_COMMAND: LexicalCommand = createCommand(
'REMOVE_INLINE_SEARCH_COMMAND'
);
-export const UPDATE_DOCUMENT_NAME_COMMAND: LexicalCommand<
- Record
-> = createCommand('UPDATE_DOCUMENT_NAME_COMMAND');
+// Defined in ../commands so decorator components can import it without
+// pulling this plugin module into the boot bundle; re-exported for existing
+// consumers.
+export { UPDATE_DOCUMENT_NAME_COMMAND };
export const INSERT_USER_MENTION_COMMAND: LexicalCommand =
createCommand('INSERT_USER_MENTION_COMMAND');
diff --git a/js/app/packages/core/component/LexicalMarkdown/plugins/pluginManager.ts b/js/app/packages/core/component/LexicalMarkdown/plugins/pluginManager.ts
index b90a8eb9e2..2d0ef94bcc 100644
--- a/js/app/packages/core/component/LexicalMarkdown/plugins/pluginManager.ts
+++ b/js/app/packages/core/component/LexicalMarkdown/plugins/pluginManager.ts
@@ -15,7 +15,6 @@ import {
on,
type Setter,
} from 'solid-js';
-import { registerLoroHistory } from '../collaboration/undo';
import { bindStateAs } from '../utils';
import { checklistPlugin } from './checklist/';
import { customDeletePlugin } from './custom-delete';
@@ -33,9 +32,20 @@ export function createPluginManager(editor: LexicalEditor, type: EditorType) {
const pluginManager = {
history(timeGap = 400, loroManager?: LoroManager) {
if (type === 'markdown-sync' && loroManager) {
- cleanupFunctions.push(
- registerLoroHistory(editor, loroManager.getDoc(), timeGap)
- );
+ // Loaded lazily so editors without collaboration (chat inputs, task
+ // compose, ...) don't pull loro-crdt. Callers that pass a LoroManager
+ // have the collab stack loaded already, so this resolves in a
+ // microtask in practice.
+ let cleanup: (() => void) | null = null;
+ let cancelled = false;
+ void import('../collaboration/undo').then(({ registerLoroHistory }) => {
+ if (cancelled) return;
+ cleanup = registerLoroHistory(editor, loroManager.getDoc(), timeGap);
+ });
+ cleanupFunctions.push(() => {
+ cancelled = true;
+ cleanup?.();
+ });
} else {
cleanupFunctions.push(
registerHistory(editor, createEmptyHistoryState(), timeGap)
diff --git a/js/app/packages/core/component/LexicalMarkdown/utils.ts b/js/app/packages/core/component/LexicalMarkdown/utils.ts
index fa1610abf4..884f82618e 100644
--- a/js/app/packages/core/component/LexicalMarkdown/utils.ts
+++ b/js/app/packages/core/component/LexicalMarkdown/utils.ts
@@ -78,7 +78,7 @@ import { MarkdownEditorErrors } from './constants';
import {
$applyDocumentMetadataFromSerialized,
$getDocumentMetadata,
-} from './plugins';
+} from './plugins/document-metadata/documentMetadataPlugin';
import { MARKDOWN_VERSION_COUNTER } from './version';
/**
diff --git a/js/app/packages/core/component/TopBar/ShareButton.tsx b/js/app/packages/core/component/TopBar/ShareButton.tsx
index c37411dd36..9981f66ce5 100644
--- a/js/app/packages/core/component/TopBar/ShareButton.tsx
+++ b/js/app/packages/core/component/TopBar/ShareButton.tsx
@@ -89,6 +89,11 @@ import { Permissions } from '../SharePermissions';
import { toast } from '../Toast/Toast';
import { ScrollIndicators } from '../VerticalScrollIndicators';
import { openLoginModal } from './LoginButton';
+import {
+ addShareButtonRefetch,
+ removeShareButtonRefetch,
+} from './shareButtonRefetch';
+import { getShareDrawerRecipientInput } from './shareDrawer';
false && clickOutside;
@@ -134,9 +139,9 @@ const permissionsBlockResource = createBlockResource(
createBlockEffect(() => {
const [, { refetch }] = permissionsBlockResource;
- setRefetchArray((prev) => [...prev, refetch]);
+ addShareButtonRefetch(refetch);
onCleanup(() => {
- setRefetchArray((prev) => prev.filter((r) => r !== refetch));
+ removeShareButtonRefetch(refetch);
});
});
@@ -159,21 +164,13 @@ const accessLevelText = (accessLevel?: AccessLevel | null) => {
}
};
-const [refetchArray, setRefetchArray] = createSignal<(() => void)[]>([]);
-export const refetchDocumentShareButtonResource = () => {
- const refetchArray_ = refetchArray();
- if (refetchArray_.length === 0) {
- console.warn('no document share permission refetch functions initialized');
- return;
- }
- refetchArray_.forEach((refetch) => refetch());
-};
+// Moved to ./shareButtonRefetch so non-UI callers don't pull this module;
+// re-exported for existing imports.
+export { refetchDocumentShareButtonResource } from './shareButtonRefetch';
-export function getShareDrawerRecipientInput(): HTMLElement | null {
- return document.querySelector(
- '[data-share-drawer-recipient] input'
- );
-}
+// Moved to ./shareDrawer so light consumers don't pull this module;
+// re-exported for existing imports.
+export { getShareDrawerRecipientInput };
interface ShareModalProps {
setIsSharePermOpen: (value: boolean) => void;
diff --git a/js/app/packages/core/component/TopBar/shareButtonRefetch.ts b/js/app/packages/core/component/TopBar/shareButtonRefetch.ts
new file mode 100644
index 0000000000..1fc16ba441
--- /dev/null
+++ b/js/app/packages/core/component/TopBar/shareButtonRefetch.ts
@@ -0,0 +1,24 @@
+import { createSignal } from 'solid-js';
+
+// Registry of mounted share-button permission resources. Lives apart from
+// ShareButton.tsx so non-UI callers (service-storage refetchResources) don't
+// pull the share modal UI into the initial bundle.
+
+const [refetchArray, setRefetchArray] = createSignal<(() => void)[]>([]);
+
+export const addShareButtonRefetch = (refetch: () => void) => {
+ setRefetchArray((prev) => [...prev, refetch]);
+};
+
+export const removeShareButtonRefetch = (refetch: () => void) => {
+ setRefetchArray((prev) => prev.filter((r) => r !== refetch));
+};
+
+export const refetchDocumentShareButtonResource = () => {
+ const refetchArray_ = refetchArray();
+ if (refetchArray_.length === 0) {
+ console.warn('no document share permission refetch functions initialized');
+ return;
+ }
+ refetchArray_.forEach((refetch) => refetch());
+};
diff --git a/js/app/packages/core/component/TopBar/shareDrawer.ts b/js/app/packages/core/component/TopBar/shareDrawer.ts
new file mode 100644
index 0000000000..14fe1f04e7
--- /dev/null
+++ b/js/app/packages/core/component/TopBar/shareDrawer.ts
@@ -0,0 +1,7 @@
+// Lives apart from ShareButton.tsx so light consumers (e.g. the soup entity
+// action drawer) don't pull the share modal UI into the initial bundle.
+export function getShareDrawerRecipientInput(): HTMLElement | null {
+ return document.querySelector(
+ '[data-share-drawer-recipient] input'
+ );
+}
diff --git a/js/app/packages/entity/src/extractors-search/search-content.tsx b/js/app/packages/entity/src/extractors-search/search-content.tsx
index aa3f9f309e..77a4886539 100644
--- a/js/app/packages/entity/src/extractors-search/search-content.tsx
+++ b/js/app/packages/entity/src/extractors-search/search-content.tsx
@@ -1,10 +1,17 @@
-import { StaticMarkdown } from '@core/component/LexicalMarkdown/component/core/StaticMarkdown';
+// Lazy: StaticMarkdown pulls the markdown parsing stack (@lexical/markdown,
+// transformers, prism), which would otherwise load with the initial bundle.
+const StaticMarkdown = lazy(() =>
+ import('@core/component/LexicalMarkdown/component/core/StaticMarkdown').then(
+ (m) => ({ default: m.StaticMarkdown })
+ )
+);
+
import {
searchContentHitMarkdownTheme,
singleLineMarkdownTheme,
twoLineClampMarkdownTheme,
} from '@core/component/LexicalMarkdown/theme';
-import { Show } from 'solid-js';
+import { lazy, Show, Suspense } from 'solid-js';
import type { ContentHitData } from '../types/search';
interface SearchContentProps {
@@ -36,7 +43,9 @@ export function SearchContent(props: SearchContentProps) {
fallback={No content}
>
{(trimmedContent) => (
-
+
+
+
)}
)}
diff --git a/js/app/packages/entity/src/extractors/entity-title.tsx b/js/app/packages/entity/src/extractors/entity-title.tsx
index 2af6ca0867..1261395438 100644
--- a/js/app/packages/entity/src/extractors/entity-title.tsx
+++ b/js/app/packages/entity/src/extractors/entity-title.tsx
@@ -1,8 +1,15 @@
-import { StaticMarkdown } from '@core/component/LexicalMarkdown/component/core/StaticMarkdown';
+// Lazy: StaticMarkdown pulls the markdown parsing stack; until it loads the
+// raw text renders as the Suspense fallback.
+const StaticMarkdown = lazy(() =>
+ import('@core/component/LexicalMarkdown/component/core/StaticMarkdown').then(
+ (m) => ({ default: m.StaticMarkdown })
+ )
+);
+
import { unifiedListMarkdownTheme } from '@core/component/LexicalMarkdown/theme';
import { blockNameToDefaultFile } from '@core/constant/allBlocks';
import { formatDocumentName } from '@service-storage/util/filename';
-import { type JSX, Show } from 'solid-js';
+import { type JSX, lazy, Show, Suspense } from 'solid-js';
import { match } from 'ts-pattern';
import { type EntityData, isGithubPrEntity } from '../types/entity';
import { isSearchEntity } from '../types/search';
@@ -67,11 +74,13 @@ export function EntityTitle(props: { entity: EntityData }) {
when={titleData().isMarkdown}
fallback={{titleData().text}}
>
-
+ {titleData().text}}>
+
+
);
}
diff --git a/js/app/packages/notifications/index.ts b/js/app/packages/notifications/index.ts
index 8d81a66659..6eb16855c9 100644
--- a/js/app/packages/notifications/index.ts
+++ b/js/app/packages/notifications/index.ts
@@ -15,7 +15,6 @@ export {
PlatformNotificationProvider,
usePlatformNotificationState,
} from './components/PlatformNotificationProvider';
-export { NotificationsPlayground } from './components/Playground';
export { createTabLeaderSignal } from './notification-election';
export {
createEffectOnEntityTypeNotification,
diff --git a/js/app/packages/notifications/notification-navigation.ts b/js/app/packages/notifications/notification-navigation.ts
index 36e583c22e..596ee48c8c 100644
--- a/js/app/packages/notifications/notification-navigation.ts
+++ b/js/app/packages/notifications/notification-navigation.ts
@@ -4,7 +4,7 @@ import {
navigateToChannelMessage,
} from '@block-channel/utils/link';
import { URL_PARAMS as MD_URL_PARAMS } from '@block-md/constants';
-import { URL_PARAMS as PDF_URL_PARAMS } from '@block-pdf/signal/location';
+import { URL_PARAMS as PDF_URL_PARAMS } from '@block-pdf/constants';
import type { BlockAlias, BlockName } from '@core/block';
import {
type ItemLike,
diff --git a/js/app/packages/notifications/notification-platform.ts b/js/app/packages/notifications/notification-platform.ts
index 8fe9bc7520..e5c3bf8e19 100644
--- a/js/app/packages/notifications/notification-platform.ts
+++ b/js/app/packages/notifications/notification-platform.ts
@@ -1,6 +1,6 @@
import type { SplitManager } from '@app/component/split-layout/layoutManager';
import { getFaviconUrl } from '@app/util/favicon';
-import { markdownToPlainText } from '@lexical-core';
+import { markdownToPlainText } from '@lexical-core/utils/parsers';
import { themeReactive } from '../theme/signals/themeReactive';
import type { PlatformNotificationState } from './components/PlatformNotificationProvider';
import { GITHUB_EVENT_TYPES } from './github-event-types';
diff --git a/js/app/packages/notifications/notification-source.ts b/js/app/packages/notifications/notification-source.ts
index 87cf759b39..a83ebe02d2 100644
--- a/js/app/packages/notifications/notification-source.ts
+++ b/js/app/packages/notifications/notification-source.ts
@@ -31,9 +31,10 @@ import { createMutedEntitiesQuery } from './queries/muted-entities-query';
import {
type CompositeEntity,
compositeEntity,
+ getUnifiedNotificationSchema,
+ loadUnifiedNotificationSchema,
notificationEntity,
type UnifiedNotification,
- unifiedNotificationSchema,
} from './types';
export const CHANNEL_EVENT_TYPES = [
@@ -211,6 +212,10 @@ export function createNotificationSource(
};
};
+ // Kick off the lazy schema load so it is ready before the first
+ // notification arrives over the websocket.
+ void loadUnifiedNotificationSchema();
+
createSocketEffect(ws, (wsData) => {
if (wsData.type !== NOTIFICATION_EVENT_TYPE) {
return;
@@ -219,16 +224,23 @@ export function createNotificationSource(
try {
const raw = JSON.parse(wsData.data) as ConnGatewayInnerNotifValue;
const unsafeMapped = mapWebsocketNotification(raw);
- const parseResult = unifiedNotificationSchema.safeParse(unsafeMapped);
- if (!parseResult.success) {
- console.warn(
- 'Failed to parse notification',
- wsData.data,
- fromZodError(parseResult.error)
- );
+ // Schema may still be loading right after boot; use the unvalidated
+ // value, exactly like the parse-failure path below.
+ const schema = getUnifiedNotificationSchema();
+ if (!schema) {
parsedNotification = unsafeMapped;
} else {
- parsedNotification = parseResult.data;
+ const parseResult = schema.safeParse(unsafeMapped);
+ if (!parseResult.success) {
+ console.warn(
+ 'Failed to parse notification',
+ wsData.data,
+ fromZodError(parseResult.error)
+ );
+ parsedNotification = unsafeMapped;
+ } else {
+ parsedNotification = parseResult.data;
+ }
}
} catch (e) {
console.error('Failed to parse notification', wsData.data, e);
diff --git a/js/app/packages/notifications/types.ts b/js/app/packages/notifications/types.ts
index e77de0a0ca..7c5d1828e4 100644
--- a/js/app/packages/notifications/types.ts
+++ b/js/app/packages/notifications/types.ts
@@ -1,15 +1,41 @@
import type { Entity, EntityType } from '@core/types';
import type { ApiUserNotification } from '@service-notification/generated/schemas';
-import { listTypedNotificationsResponse } from '@service-notification/generated/zod';
export type UnifiedNotification = Omit;
-const _baseSchema = listTypedNotificationsResponse.shape.items.element;
-const _entitySchema = _baseSchema._def.left;
-const _allOfSchema = _baseSchema._def.right;
-export const unifiedNotificationSchema = _entitySchema.and(
- _allOfSchema.omit({ owner_id: true })
-);
+type NotificationZodModule =
+ typeof import('@service-notification/generated/zod');
+
+function buildUnifiedNotificationSchema(m: NotificationZodModule) {
+ const baseSchema = m.listTypedNotificationsResponse.shape.items.element;
+ const entitySchema = baseSchema._def.left;
+ const allOfSchema = baseSchema._def.right;
+ return entitySchema.and(allOfSchema.omit({ owner_id: true }));
+}
+
+export type UnifiedNotificationSchema = ReturnType<
+ typeof buildUnifiedNotificationSchema
+>;
+
+// The generated zod module is large and zod schema construction isn't
+// tree-shakeable, so it loads lazily instead of with the initial bundle.
+// Callers parse with the schema once loaded and fall back to the unvalidated
+// value until then — the same fallback the parse-failure path already takes.
+let loadedSchema: UnifiedNotificationSchema | null = null;
+let schemaPromise: Promise | null = null;
+
+export function loadUnifiedNotificationSchema(): Promise {
+ schemaPromise ??= import('@service-notification/generated/zod').then((m) => {
+ loadedSchema = buildUnifiedNotificationSchema(m);
+ return loadedSchema;
+ });
+ return schemaPromise;
+}
+
+/** Sync access; null until loadUnifiedNotificationSchema() resolves. */
+export function getUnifiedNotificationSchema(): UnifiedNotificationSchema | null {
+ return loadedSchema;
+}
export type CompositeEntity = `${EntityType}@${string}`;
diff --git a/js/app/packages/observability/src/actionTracker.ts b/js/app/packages/observability/src/actionTracker.ts
index 9b99677a4b..bd8a9a3bb9 100644
--- a/js/app/packages/observability/src/actionTracker.ts
+++ b/js/app/packages/observability/src/actionTracker.ts
@@ -1,8 +1,8 @@
-import { datadogRum } from '@datadog/browser-rum';
-import { isInitialized } from './shared';
+import { getImpl, isInitialized } from './shared';
export function startAction(name: string, context?: object) {
- if (!isInitialized()) return;
+ const impl = getImpl();
+ if (!isInitialized() || !impl) return;
- datadogRum.addAction(name, context);
+ impl.addAction(name, context);
}
diff --git a/js/app/packages/observability/src/impl.ts b/js/app/packages/observability/src/impl.ts
new file mode 100644
index 0000000000..4d2e2ef04e
--- /dev/null
+++ b/js/app/packages/observability/src/impl.ts
@@ -0,0 +1,124 @@
+///
+
+// All code that touches the Datadog SDKs lives here. This module is only
+// loaded via dynamic import from init() so the SDKs stay out of the initial
+// bundle; everything else in the package goes through shared.ts getImpl().
+
+import { SERVER_HOSTS } from '@core/constant/servers';
+import { datadogLogs } from '@datadog/browser-logs';
+import { datadogRum } from '@datadog/browser-rum';
+
+const applicationId = import.meta.env.VITE_DD_WEB_APP_ID;
+const clientToken = import.meta.env.VITE_DD_WEB_APP_TOKEN;
+const env = import.meta.env.MODE === 'production' ? 'prod' : 'dev';
+const service = 'web-app';
+const site = 'us5.datadoghq.com';
+
+const tracingHosts =
+ env === 'prod'
+ ? [
+ SERVER_HOSTS['auth-service'],
+ SERVER_HOSTS['cognition-service'],
+ SERVER_HOSTS['document-storage-service'],
+ SERVER_HOSTS['email-service'],
+ SERVER_HOSTS['notification-service'],
+ ]
+ : Object.values(SERVER_HOSTS);
+
+export function init(version: string) {
+ datadogRum.init({
+ applicationId,
+ clientToken,
+ env,
+ version,
+ service,
+ site,
+ sessionSampleRate: 100,
+ sessionReplaySampleRate: 0,
+ allowFallbackToLocalStorage: true,
+ trackUserInteractions: true,
+ trackResources: true,
+ trackLongTasks: true,
+ actionNameAttribute: 'data-action-name',
+ defaultPrivacyLevel: 'mask',
+ excludedActivityUrls: [
+ (url) => new URL(url).hostname.includes('analytics'),
+ ],
+ allowedTracingUrls: tracingHosts.map((host) => ({
+ match: host,
+ propagatorTypes: ['tracecontext'],
+ })),
+ trackViewsManually: true,
+ beforeSend: (event, _context) => {
+ if (event.type === 'resource' && event.status_code !== 200) {
+ if (event.resource.url.includes('unfurl-service')) return false;
+ }
+
+ // these are from VList and can be ignored: https://github.com/inokawa/virtua?tab=readme-ov-file#what-is-resizeobserver-loop-completed-with-undelivered-notifications-error
+ if (
+ event.type === 'error' &&
+ event.error.message.includes(
+ 'ResizeObserver loop completed with undelivered notifications'
+ )
+ )
+ return false;
+
+ return true;
+ },
+ });
+
+ datadogLogs.init({
+ clientToken,
+ env,
+ version,
+ service,
+ site,
+ telemetrySampleRate: 0,
+ beforeSend: (event, _context) => {
+ if (event.message.includes('unfurl-service')) return false;
+
+ // these are from VList and can be ignored: https://github.com/inokawa/virtua?tab=readme-ov-file#what-is-resizeobserver-loop-completed-with-undelivered-notifications-error
+ if (
+ event.message.includes(
+ 'ResizeObserver loop completed with undelivered notifications'
+ )
+ )
+ return false;
+
+ return true;
+ },
+ });
+}
+
+export function setUser(user: {
+ id: string;
+ email: string;
+ [key: string]: any;
+}) {
+ datadogRum.setUser(user);
+ datadogLogs.setUser(user);
+}
+
+export function logMessage(
+ message: string,
+ messageContext: object | undefined,
+ level: 'debug' | 'info' | 'warn' | 'error',
+ error?: Error
+) {
+ datadogLogs.logger.log(message, messageContext, level, error);
+}
+
+export function logError(error: Error, errorContext?: object) {
+ datadogLogs.logger.error(error.message || error.name, errorContext, error);
+}
+
+export function addAction(name: string, context?: object) {
+ datadogRum.addAction(name, context);
+}
+
+export function startView(options: {
+ name: string;
+ context?: Record;
+}) {
+ datadogRum.startView(options);
+}
diff --git a/js/app/packages/observability/src/index.ts b/js/app/packages/observability/src/index.ts
index bee094fe0a..e3301f9159 100644
--- a/js/app/packages/observability/src/index.ts
+++ b/js/app/packages/observability/src/index.ts
@@ -1,104 +1,44 @@
///
-import { SERVER_HOSTS } from '@core/constant/servers';
-import { datadogLogs } from '@datadog/browser-logs';
-import { datadogRum } from '@datadog/browser-rum';
-import { isInitialized, setInitialized } from './shared';
+import { getImpl, isInitialized, setImpl, setInitialized } from './shared';
-const applicationId = import.meta.env.VITE_DD_WEB_APP_ID;
-const clientToken = import.meta.env.VITE_DD_WEB_APP_TOKEN;
-const env = import.meta.env.MODE === 'production' ? 'prod' : 'dev';
-const service = 'web-app';
-const site = 'us5.datadoghq.com';
+interface User {
+ id: string;
+ email: string;
+ [key: string]: any;
+}
-const tracingHosts =
- env === 'prod'
- ? [
- SERVER_HOSTS['auth-service'],
- SERVER_HOSTS['cognition-service'],
- SERVER_HOSTS['document-storage-service'],
- SERVER_HOSTS['email-service'],
- SERVER_HOSTS['notification-service'],
- ]
- : Object.values(SERVER_HOSTS);
+let pendingUser: User | null = null;
-export function init(version = import.meta.env.__APP_VERSION__) {
+/**
+ * Loads and initializes Datadog RUM + logs. The Datadog SDKs are imported
+ * lazily so they stay out of the initial bundle; until this resolves, logging
+ * falls back to the console (identical to the previous pre-init behavior).
+ */
+export async function init(version = import.meta.env.__APP_VERSION__) {
if (import.meta.hot || isInitialized()) return;
- datadogRum.init({
- applicationId,
- clientToken,
- env,
- version,
- service,
- site,
- sessionSampleRate: 100,
- sessionReplaySampleRate: 0,
- allowFallbackToLocalStorage: true,
- trackUserInteractions: true,
- trackResources: true,
- trackLongTasks: true,
- actionNameAttribute: 'data-action-name',
- defaultPrivacyLevel: 'mask',
- excludedActivityUrls: [
- (url) => new URL(url).hostname.includes('analytics'),
- ],
- allowedTracingUrls: tracingHosts.map((host) => ({
- match: host,
- propagatorTypes: ['tracecontext'],
- })),
- trackViewsManually: true,
- beforeSend: (event, _context) => {
- if (event.type === 'resource' && event.status_code !== 200) {
- if (event.resource.url.includes('unfurl-service')) return false;
- }
-
- // these are from VList and can be ignored: https://github.com/inokawa/virtua?tab=readme-ov-file#what-is-resizeobserver-loop-completed-with-undelivered-notifications-error
- if (
- event.type === 'error' &&
- event.error.message.includes(
- 'ResizeObserver loop completed with undelivered notifications'
- )
- )
- return false;
-
- return true;
- },
- });
-
- datadogLogs.init({
- clientToken,
- env,
- version,
- service,
- site,
- telemetrySampleRate: 0,
- beforeSend: (event, _context) => {
- if (event.message.includes('unfurl-service')) return false;
-
- // these are from VList and can be ignored: https://github.com/inokawa/virtua?tab=readme-ov-file#what-is-resizeobserver-loop-completed-with-undelivered-notifications-error
- if (
- event.message.includes(
- 'ResizeObserver loop completed with undelivered notifications'
- )
- )
- return false;
-
- return true;
- },
- });
+ const impl = await import('./impl');
+ if (isInitialized()) return;
+ impl.init(version);
+ setImpl(impl);
setInitialized(true);
-}
-interface User {
- id: string;
- email: string;
- [key: string]: any;
+ if (pendingUser) {
+ impl.setUser(pendingUser);
+ pendingUser = null;
+ }
}
+
export function setUser(user: User) {
- datadogRum.setUser(user);
- datadogLogs.setUser(user);
+ const impl = getImpl();
+ if (impl) {
+ impl.setUser(user);
+ } else {
+ // Applied as soon as init() finishes loading the SDKs.
+ pendingUser = user;
+ }
}
export { startAction } from './actionTracker';
diff --git a/js/app/packages/observability/src/logger.ts b/js/app/packages/observability/src/logger.ts
index 0eed3eea8f..a8a25a1883 100644
--- a/js/app/packages/observability/src/logger.ts
+++ b/js/app/packages/observability/src/logger.ts
@@ -1,5 +1,4 @@
-import { datadogLogs } from '@datadog/browser-logs';
-import { isInitialized } from './shared';
+import { getImpl, isInitialized } from './shared';
interface Context {
error?: Error;
@@ -13,7 +12,8 @@ interface Context {
* @param context - The context of the log such as error, level, etc.
*/
export function log(messsage: string, context?: Context) {
- if (import.meta.hot || !isInitialized())
+ const impl = getImpl();
+ if (import.meta.hot || !isInitialized() || !impl)
return console.log(messsage, context);
const { error, level, ...messageContext } = {
@@ -22,7 +22,7 @@ export function log(messsage: string, context?: Context) {
...context,
} as const;
- datadogLogs.logger.log(messsage, messageContext, level, error);
+ impl.logMessage(messsage, messageContext, level, error);
}
interface ErrorContext extends Context {
@@ -35,7 +35,8 @@ interface ErrorContext extends Context {
* @param context - The context of the log such as error, level, etc.
*/
function warn(message: string, context?: Context) {
- if (import.meta.hot || !isInitialized())
+ const impl = getImpl();
+ if (import.meta.hot || !isInitialized() || !impl)
return console.warn(message, context);
const { error, level, ...messageContext } = {
@@ -44,7 +45,7 @@ function warn(message: string, context?: Context) {
...context,
} as const;
- datadogLogs.logger.log(message, messageContext, level, error);
+ impl.logMessage(message, messageContext, level, error);
}
/**
@@ -55,7 +56,8 @@ function warn(message: string, context?: Context) {
* If used in a catch block, set the context.cause to the error thrown.
*/
export function error(errorMessage: Error | string, context?: ErrorContext) {
- if (import.meta.hot || !isInitialized())
+ const impl = getImpl();
+ if (import.meta.hot || !isInitialized() || !impl)
return console.error(errorMessage, context);
const { error, ...errorContext } = {
@@ -69,7 +71,7 @@ export function error(errorMessage: Error | string, context?: ErrorContext) {
error.cause = context.cause;
}
- datadogLogs.logger.error(error.message || error.name, errorContext, error);
+ impl.logError(error, errorContext);
}
export const logger = {
diff --git a/js/app/packages/observability/src/routingTracker.ts b/js/app/packages/observability/src/routingTracker.ts
index fcf49e3d7b..26be1c8409 100644
--- a/js/app/packages/observability/src/routingTracker.ts
+++ b/js/app/packages/observability/src/routingTracker.ts
@@ -1,7 +1,6 @@
-import { datadogRum } from '@datadog/browser-rum';
import { useLocation } from '@solidjs/router';
import { createEffect, createMemo, on } from 'solid-js';
-import { isInitialized } from './shared';
+import { getImpl, isInitialized } from './shared';
export function useObserveRouting() {
const location = useLocation();
@@ -16,10 +15,11 @@ export function useObserveRouting() {
: pathSegments().at(0);
createEffect(
on(viewName, (name, prevName) => {
- if (!isInitialized() || !name) return;
+ const impl = getImpl();
+ if (!isInitialized() || !impl || !name) return;
if (name !== prevName) {
- datadogRum.startView({
+ impl.startView({
name,
context: {
pathname: location.pathname,
@@ -37,9 +37,10 @@ export function useObserveRouting() {
createEffect((prevSplits) => {
const splits = joinedPath();
- if (!isInitialized()) return;
+ const impl = getImpl();
+ if (!isInitialized() || !impl) return;
if (splits !== prevSplits) {
- datadogRum.addAction('split changed', {
+ impl.addAction('split changed', {
from: prevSplits,
to: splits,
});
diff --git a/js/app/packages/observability/src/shared.ts b/js/app/packages/observability/src/shared.ts
index 50e670a034..aab0f90904 100644
--- a/js/app/packages/observability/src/shared.ts
+++ b/js/app/packages/observability/src/shared.ts
@@ -1,4 +1,8 @@
+type ImplModule = typeof import('./impl');
+
let initialized = false;
+let impl: ImplModule | null = null;
+
export function isInitialized() {
return initialized;
}
@@ -6,3 +10,12 @@ export function isInitialized() {
export function setInitialized(value: boolean) {
initialized = value;
}
+
+/** The lazily-loaded Datadog module; null until init() resolves. */
+export function getImpl() {
+ return impl;
+}
+
+export function setImpl(module: ImplModule) {
+ impl = module;
+}
diff --git a/js/app/packages/queries/storage/instructions-md.ts b/js/app/packages/queries/storage/instructions-md.ts
index 767545df9b..152da70d93 100644
--- a/js/app/packages/queries/storage/instructions-md.ts
+++ b/js/app/packages/queries/storage/instructions-md.ts
@@ -1,9 +1,3 @@
-import { createLexicalWrapper } from '@core/component/LexicalMarkdown/context/LexicalWrapperContext';
-import {
- getTextContent,
- initializeEditorWithState,
-} from '@core/component/LexicalMarkdown/utils';
-
import { storageServiceClient } from '@service-storage/client';
import { syncServiceClient } from '@service-sync/client';
import { useQuery } from '@tanstack/solid-query';
@@ -62,6 +56,16 @@ const getInstructionsMdText = async (id: string | null | undefined) => {
documentId: id,
});
+ // Loaded lazily: this is the only boot-path consumer of the editor
+ // machinery, which would otherwise land in the initial bundle.
+ const [
+ { createLexicalWrapper },
+ { getTextContent, initializeEditorWithState },
+ ] = await Promise.all([
+ import('@core/component/LexicalMarkdown/context/LexicalWrapperContext'),
+ import('@core/component/LexicalMarkdown/utils'),
+ ]);
+
const { editor } = createLexicalWrapper({
type: 'markdown',
namespace: 'instructions-md-text-extractor',
diff --git a/js/app/packages/service-clients/service-storage/client.ts b/js/app/packages/service-clients/service-storage/client.ts
index 9fa7b934ff..b45c99c675 100644
--- a/js/app/packages/service-clients/service-storage/client.ts
+++ b/js/app/packages/service-clients/service-storage/client.ts
@@ -132,7 +132,6 @@ import type { TypedSuccessResponse } from './generated/schemas/typedSuccessRespo
import type { UploadExtractFolderHandler200 } from './generated/schemas/uploadExtractFolderHandler200';
import type { UserPinsResponse } from './generated/schemas/userPinsResponse';
import type { UserViewsResponse } from './generated/schemas/userViewsResponse';
-import { saveDocumentHandlerResponse } from './generated/zod';
import type {
GetDocumentPermissionsTokenResponse,
StorageServiceClient,
@@ -144,6 +143,24 @@ import {
getDocxExpandedParts,
} from './util/getDocxFile';
+// Loaded lazily: generated/zod.ts is ~700KB of source and zod schema
+// construction isn't tree-shakeable, so statically importing this one schema
+// would retain the whole file in the initial bundle. Every call site runs
+// after an awaited fetch, so the async accessor costs nothing extra.
+type StorageZodModule = typeof import('./generated/zod');
+type SavedDocumentMetadataSchema =
+ StorageZodModule['saveDocumentHandlerResponse']['shape']['data']['shape']['documentMetadata'];
+
+let savedDocumentMetadataSchemaPromise: Promise | null =
+ null;
+
+function getSavedDocumentMetadataSchema(): Promise {
+ savedDocumentMetadataSchemaPromise ??= import('./generated/zod').then(
+ (m) => m.saveDocumentHandlerResponse.shape.data.shape.documentMetadata
+ );
+ return savedDocumentMetadataSchemaPromise;
+}
+
function normalizeLocationResponseV3(response: LocationResponseV3) {
return response;
}
@@ -1462,10 +1479,9 @@ export const storageServiceClient = {
const { data } = result.value;
- const metadata =
- saveDocumentHandlerResponse.shape.data.shape.documentMetadata.safeParse(
- data.documentMetadata
- );
+ const metadata = (await getSavedDocumentMetadataSchema()).safeParse(
+ data.documentMetadata
+ );
if (!metadata.success) {
return err([
{
@@ -1492,10 +1508,9 @@ export const storageServiceClient = {
const { data } = result.value;
- const metadata =
- saveDocumentHandlerResponse.shape.data.shape.documentMetadata.safeParse(
- data.documentMetadata
- );
+ const metadata = (await getSavedDocumentMetadataSchema()).safeParse(
+ data.documentMetadata
+ );
if (!metadata.success) {
return err([
{
@@ -1774,10 +1789,9 @@ export const storageServiceClient = {
const { data } = result.value;
- const metadata =
- saveDocumentHandlerResponse.shape.data.shape.documentMetadata.safeParse(
- data.documentMetadata
- );
+ const metadata = (await getSavedDocumentMetadataSchema()).safeParse(
+ data.documentMetadata
+ );
if (!metadata.success) {
return err([
{
diff --git a/js/app/packages/service-clients/service-storage/util/refetchResources.ts b/js/app/packages/service-clients/service-storage/util/refetchResources.ts
index a8a4bb3416..cd276eff8e 100644
--- a/js/app/packages/service-clients/service-storage/util/refetchResources.ts
+++ b/js/app/packages/service-clients/service-storage/util/refetchResources.ts
@@ -1,4 +1,4 @@
-import { refetchDocumentShareButtonResource } from '@core/component/TopBar/ShareButton';
+import { refetchDocumentShareButtonResource } from '@core/component/TopBar/shareButtonRefetch';
import { invalidateUserQuota } from '@queries/auth';
import { refetchHistory } from '@queries/history/history';
import { invalidatePreview } from '@queries/preview';
diff --git a/js/app/packages/tauri/src/TauriProvider.tsx b/js/app/packages/tauri/src/TauriProvider.tsx
index 1e5bd0c6d5..24f31a6d1d 100644
--- a/js/app/packages/tauri/src/TauriProvider.tsx
+++ b/js/app/packages/tauri/src/TauriProvider.tsx
@@ -1,4 +1,5 @@
-import { NativeCallProvider, useCallKitSetup } from '@channel/Call';
+import { NativeCallProvider } from '@channel/Call/native-call-state';
+import { useCallKitSetup } from '@channel/Call/use-callkit';
import { NativeAppUpdateRequiredDialog } from '@core/mobile/NativeAppUpdateRequiredDialog';
import { isPlatform, isTauri } from '@core/util/platform';
import { PlatformNotificationProvider } from '@notifications';
diff --git a/js/lexical-core/nodes/DiffNode.ts b/js/lexical-core/nodes/DiffNode.ts
index 13a313042d..edba42e745 100644
--- a/js/lexical-core/nodes/DiffNode.ts
+++ b/js/lexical-core/nodes/DiffNode.ts
@@ -1,4 +1,3 @@
-import { $convertFromMarkdownString } from '@lexical/markdown';
import { $unwrapNode } from '@lexical/utils';
import {
$getSelection,
@@ -14,7 +13,6 @@ import {
} from 'lexical';
import { createDOMWithFactory } from '../domFactoryRegistry';
import { $applyIdFromSerialized } from '../plugins/nodeIdPlugin';
-import { ALL_TRANSFORMERS } from '../transformers';
import { $findDiffDeleteNodeAncestor } from './DiffDeleteNode';
import { $isDiffInsertNode } from './DiffInsertNode';
@@ -113,24 +111,34 @@ export class DiffNode extends ElementNode {
}
handleAccept(editor: LexicalEditor) {
- editor.update(() => {
- const diffInsertNode = this.getChildren().find((child) =>
- $isDiffInsertNode(child)
- );
- if (!diffInsertNode) return;
-
- const insertMarkdown = diffInsertNode.getMarkdown();
- if (insertMarkdown === undefined) return;
-
- this.clear();
-
- $convertFromMarkdownString(insertMarkdown, ALL_TRANSFORMERS, this, false);
-
- const lastChild = this.getLastChild();
- $unwrapNode(this);
- if (lastChild) {
- lastChild.selectEnd();
- }
+ // The markdown conversion stack loads lazily: this node class is
+ // registered at app boot, and importing @lexical/markdown + the
+ // transformer list eagerly would pull them into the initial bundle.
+ // handleAccept is a user action (accepting a diff), so deferring the
+ // update until the one-time chunk load resolves is fine.
+ void Promise.all([
+ import('@lexical/markdown'),
+ import('../transformers'),
+ ]).then(([{ $convertFromMarkdownString }, { ALL_TRANSFORMERS }]) => {
+ editor.update(() => {
+ const diffInsertNode = this.getChildren().find((child) =>
+ $isDiffInsertNode(child)
+ );
+ if (!diffInsertNode) return;
+
+ const insertMarkdown = diffInsertNode.getMarkdown();
+ if (insertMarkdown === undefined) return;
+
+ this.clear();
+
+ $convertFromMarkdownString(insertMarkdown, ALL_TRANSFORMERS, this, false);
+
+ const lastChild = this.getLastChild();
+ $unwrapNode(this);
+ if (lastChild) {
+ lastChild.selectEnd();
+ }
+ });
});
}