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(); + } + }); }); }