diff --git a/browser_tests/tests/mobileBaseline.spec.ts-snapshots/mobile-settings-dialog-mobile-chrome-linux.png b/browser_tests/tests/mobileBaseline.spec.ts-snapshots/mobile-settings-dialog-mobile-chrome-linux.png index 6bb4ac6e3..17de9b03a 100644 Binary files a/browser_tests/tests/mobileBaseline.spec.ts-snapshots/mobile-settings-dialog-mobile-chrome-linux.png and b/browser_tests/tests/mobileBaseline.spec.ts-snapshots/mobile-settings-dialog-mobile-chrome-linux.png differ diff --git a/src/components/load3d/Load3dViewerContent.vue b/src/components/load3d/Load3dViewerContent.vue index b0ae308cb..685af08f8 100644 --- a/src/components/load3d/Load3dViewerContent.vue +++ b/src/components/load3d/Load3dViewerContent.vue @@ -121,8 +121,9 @@ const mutationObserver = ref(null) const isStandaloneMode = !props.node && props.modelUrl +// Use sync version since useLoad3dViewer is already imported (module is loaded) const viewer = props.node - ? useLoad3dService().getOrCreateViewer(toRaw(props.node)) + ? useLoad3dService().getOrCreateViewerSync(toRaw(props.node), useLoad3dViewer) : useLoad3dViewer() const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } = diff --git a/src/components/sidebar/tabs/AssetsSidebarTab.vue b/src/components/sidebar/tabs/AssetsSidebarTab.vue index 091d951c2..4bede6891 100644 --- a/src/components/sidebar/tabs/AssetsSidebarTab.vue +++ b/src/components/sidebar/tabs/AssetsSidebarTab.vue @@ -219,7 +219,9 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue' -import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue' +// Lazy-loaded to avoid pulling THREE.js into the main bundle +const Load3dViewerContent = () => + import('@/components/load3d/Load3dViewerContent.vue') import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue' import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue' import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue' diff --git a/src/composables/useCoreCommands.ts b/src/composables/useCoreCommands.ts index 19d36f52b..7e895d323 100644 --- a/src/composables/useCoreCommands.ts +++ b/src/composables/useCoreCommands.ts @@ -580,7 +580,7 @@ export function useCoreCommands(): ComfyCommand[] { versionAdded: '1.3.7', category: 'view-controls' as const, function: () => { - dialogService.showSettingsDialog() + void dialogService.showSettingsDialog() } }, { @@ -829,7 +829,7 @@ export function useCoreCommands(): ComfyCommand[] { menubarLabel: 'About ComfyUI', versionAdded: '1.6.4', function: () => { - dialogService.showSettingsDialog('about') + void dialogService.showSettingsDialog('about') } }, { diff --git a/src/composables/useHelpCenter.ts b/src/composables/useHelpCenter.ts index 3f156119f..cb9dd2700 100644 --- a/src/composables/useHelpCenter.ts +++ b/src/composables/useHelpCenter.ts @@ -72,7 +72,7 @@ export function useHelpCenter( * Show the node conflict dialog with current conflict data */ const showConflictModal = () => { - showNodeConflictDialog({ + void showNodeConflictDialog({ showAfterWhatsNew: true, dialogComponentProps: { onClose: () => { diff --git a/src/composables/useLoad3dDrag.ts b/src/composables/useLoad3dDrag.ts index d7d5ea7f0..87a8292ec 100644 --- a/src/composables/useLoad3dDrag.ts +++ b/src/composables/useLoad3dDrag.ts @@ -1,7 +1,7 @@ import { computed, ref, toValue } from 'vue' import type { MaybeRefOrGetter } from 'vue' -import { SUPPORTED_EXTENSIONS } from '@/extensions/core/load3d/interfaces' +import { SUPPORTED_EXTENSIONS } from '@/extensions/core/load3d/constants' import { t } from '@/i18n' import { useToastStore } from '@/platform/updates/common/toastStore' diff --git a/src/extensions/core/index.ts b/src/extensions/core/index.ts index 457ef244f..ba8ef73cd 100644 --- a/src/extensions/core/index.ts +++ b/src/extensions/core/index.ts @@ -11,7 +11,9 @@ import './groupNodeManage' import './groupOptions' import './imageCompare' import './imageCrop' -import './load3d' +// load3d and saveMesh are loaded on-demand to defer THREE.js (~1.8MB) +// The lazy loader triggers loading when a 3D node is used +import './load3dLazy' import './maskeditor' if (!isCloud) { await import('./nodeTemplates') @@ -20,7 +22,7 @@ import './noteNode' import './previewAny' import './rerouteNode' import './saveImageExtraOutput' -import './saveMesh' +// saveMesh is loaded on-demand with load3d (see load3dLazy.ts) import './selectionBorder' import './simpleTouchSupport' import './slotDefaults' diff --git a/src/extensions/core/load3d/constants.ts b/src/extensions/core/load3d/constants.ts new file mode 100644 index 000000000..d74f31855 --- /dev/null +++ b/src/extensions/core/load3d/constants.ts @@ -0,0 +1,16 @@ +/** + * Load3D constants that don't require THREE.js + * This file can be imported without pulling in the entire THREE.js bundle + */ + +export const SUPPORTED_EXTENSIONS = new Set([ + '.gltf', + '.glb', + '.obj', + '.fbx', + '.stl', + '.spz', + '.splat', + '.ply', + '.ksplat' +]) diff --git a/src/extensions/core/load3d/interfaces.ts b/src/extensions/core/load3d/interfaces.ts index 7beb3882a..6563eaa2d 100644 --- a/src/extensions/core/load3d/interfaces.ts +++ b/src/extensions/core/load3d/interfaces.ts @@ -1,11 +1,13 @@ -import * as THREE from 'three' -import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' -import { ViewHelper } from 'three/examples/jsm/helpers/ViewHelper' -import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader' -import { type GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader' -import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader' -import { STLLoader } from 'three/examples/jsm/loaders/STLLoader' -import { type OBJLoader2Parallel } from 'wwobjloader2' +// Use type-only imports to avoid pulling THREE.js into the main bundle +// These imports are erased at compile time and don't create runtime dependencies +import type * as THREE from 'three' +import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' +import type { ViewHelper } from 'three/examples/jsm/helpers/ViewHelper' +import type { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader' +import type { GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader' +import type { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader' +import type { STLLoader } from 'three/examples/jsm/loaders/STLLoader' +import type { OBJLoader2Parallel } from 'wwobjloader2' export type MaterialMode = | 'original' @@ -192,15 +194,3 @@ export interface LoaderManagerInterface { dispose(): void loadModel(url: string, originalFileName?: string): Promise } - -export const SUPPORTED_EXTENSIONS = new Set([ - '.gltf', - '.glb', - '.obj', - '.fbx', - '.stl', - '.spz', - '.splat', - '.ply', - '.ksplat' -]) diff --git a/src/extensions/core/load3dLazy.ts b/src/extensions/core/load3dLazy.ts new file mode 100644 index 000000000..fcb91623a --- /dev/null +++ b/src/extensions/core/load3dLazy.ts @@ -0,0 +1,53 @@ +/** + * Lazy loader for 3D extensions (Load3D, Preview3D, SaveGLB) + * + * This module defers loading of THREE.js (~1.8MB) until a 3D node is actually + * used in a workflow. The heavy imports are only loaded when: + * - A workflow containing 3D nodes is loaded + * - A user adds a 3D node from the node menu + */ + +import { useExtensionService } from '@/services/extensionService' + +const LOAD3D_NODE_TYPES = new Set(['Load3D', 'Preview3D', 'SaveGLB']) + +let load3dExtensionsLoaded = false +let load3dExtensionsLoading: Promise | null = null + +/** + * Dynamically load the 3D extensions (and THREE.js) on demand + */ +async function loadLoad3dExtensions(): Promise { + if (load3dExtensionsLoaded) return + + if (load3dExtensionsLoading) { + return load3dExtensionsLoading + } + + load3dExtensionsLoading = (async () => { + // Import both extensions - they will self-register via useExtensionService() + await Promise.all([import('./load3d'), import('./saveMesh')]) + load3dExtensionsLoaded = true + })() + + return load3dExtensionsLoading +} + +/** + * Check if a node type is a 3D node that requires THREE.js + */ +function isLoad3dNodeType(nodeTypeName: string): boolean { + return LOAD3D_NODE_TYPES.has(nodeTypeName) +} + +// Register a lightweight extension that triggers lazy loading +useExtensionService().registerExtension({ + name: 'Comfy.Load3DLazy', + + async beforeRegisterNodeDef(_nodeType, nodeData) { + // When a 3D node type is being registered, load the 3D extensions + if (isLoad3dNodeType(nodeData.name)) { + await loadLoad3dExtensions() + } + } +}) diff --git a/src/platform/cloud/subscription/composables/useSubscriptionActions.ts b/src/platform/cloud/subscription/composables/useSubscriptionActions.ts index 7c09942f0..daddf1b74 100644 --- a/src/platform/cloud/subscription/composables/useSubscriptionActions.ts +++ b/src/platform/cloud/subscription/composables/useSubscriptionActions.ts @@ -24,7 +24,7 @@ export function useSubscriptionActions() { }) const handleAddApiCredits = () => { - dialogService.showTopUpCreditsDialog() + void dialogService.showTopUpCreditsDialog() } const handleMessageSupport = async () => { diff --git a/src/renderer/extensions/linearMode/LinearPreview.vue b/src/renderer/extensions/linearMode/LinearPreview.vue index 378a579dc..dc384bef0 100644 --- a/src/renderer/extensions/linearMode/LinearPreview.vue +++ b/src/renderer/extensions/linearMode/LinearPreview.vue @@ -11,7 +11,8 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil' import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue' -import Preview3d from '@/renderer/extensions/linearMode/Preview3d.vue' +// Lazy-loaded to avoid pulling THREE.js into the main bundle +const Preview3d = () => import('@/renderer/extensions/linearMode/Preview3d.vue') import VideoPreview from '@/renderer/extensions/linearMode/VideoPreview.vue' import { getMediaType, diff --git a/src/services/dialogService.ts b/src/services/dialogService.ts index 69171c895..6cae1c59a 100644 --- a/src/services/dialogService.ts +++ b/src/services/dialogService.ts @@ -1,39 +1,62 @@ import { merge } from 'es-toolkit/compat' import type { Component } from 'vue' -import ApiNodesSignInContent from '@/components/dialog/content/ApiNodesSignInContent.vue' -import MissingNodesContent from '@/components/dialog/content/MissingNodesContent.vue' -import MissingNodesFooter from '@/components/dialog/content/MissingNodesFooter.vue' -import MissingNodesHeader from '@/components/dialog/content/MissingNodesHeader.vue' import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationDialogContent.vue' import ErrorDialogContent from '@/components/dialog/content/ErrorDialogContent.vue' -import MissingModelsWarning from '@/components/dialog/content/MissingModelsWarning.vue' import PromptDialogContent from '@/components/dialog/content/PromptDialogContent.vue' -import SignInContent from '@/components/dialog/content/SignInContent.vue' -import TopUpCreditsDialogContent from '@/components/dialog/content/TopUpCreditsDialogContent.vue' -import UpdatePasswordContent from '@/components/dialog/content/UpdatePasswordContent.vue' -import ComfyOrgHeader from '@/components/dialog/header/ComfyOrgHeader.vue' -import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue' import { t } from '@/i18n' import { useTelemetry } from '@/platform/telemetry' import { isCloud } from '@/platform/distribution/types' import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription' -import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue' import { useDialogStore } from '@/stores/dialogStore' import type { DialogComponentProps, ShowDialogOptions } from '@/stores/dialogStore' -import ImportFailedNodeContent from '@/workbench/extensions/manager/components/manager/ImportFailedNodeContent.vue' -import ImportFailedNodeFooter from '@/workbench/extensions/manager/components/manager/ImportFailedNodeFooter.vue' -import ImportFailedNodeHeader from '@/workbench/extensions/manager/components/manager/ImportFailedNodeHeader.vue' -import NodeConflictDialogContent from '@/workbench/extensions/manager/components/manager/NodeConflictDialogContent.vue' -import NodeConflictFooter from '@/workbench/extensions/manager/components/manager/NodeConflictFooter.vue' -import NodeConflictHeader from '@/workbench/extensions/manager/components/manager/NodeConflictHeader.vue' import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes' import type { ComponentAttrs } from 'vue-component-type-helpers' +// Type-only imports for ComponentAttrs inference (no runtime cost) +import type MissingNodesContent from '@/components/dialog/content/MissingNodesContent.vue' +import type MissingModelsWarning from '@/components/dialog/content/MissingModelsWarning.vue' + +// Lazy loaders for dialogs - components are loaded on first use +const lazyMissingNodesContent = () => + import('@/components/dialog/content/MissingNodesContent.vue') +const lazyMissingNodesHeader = () => + import('@/components/dialog/content/MissingNodesHeader.vue') +const lazyMissingNodesFooter = () => + import('@/components/dialog/content/MissingNodesFooter.vue') +const lazyMissingModelsWarning = () => + import('@/components/dialog/content/MissingModelsWarning.vue') +const lazyApiNodesSignInContent = () => + import('@/components/dialog/content/ApiNodesSignInContent.vue') +const lazySignInContent = () => + import('@/components/dialog/content/SignInContent.vue') +const lazyTopUpCreditsDialogContent = () => + import('@/components/dialog/content/TopUpCreditsDialogContent.vue') +const lazyUpdatePasswordContent = () => + import('@/components/dialog/content/UpdatePasswordContent.vue') +const lazyComfyOrgHeader = () => + import('@/components/dialog/header/ComfyOrgHeader.vue') +const lazySettingDialogHeader = () => + import('@/components/dialog/header/SettingDialogHeader.vue') +const lazySettingDialogContent = () => + import('@/platform/settings/components/SettingDialogContent.vue') +const lazyImportFailedNodeContent = () => + import('@/workbench/extensions/manager/components/manager/ImportFailedNodeContent.vue') +const lazyImportFailedNodeHeader = () => + import('@/workbench/extensions/manager/components/manager/ImportFailedNodeHeader.vue') +const lazyImportFailedNodeFooter = () => + import('@/workbench/extensions/manager/components/manager/ImportFailedNodeFooter.vue') +const lazyNodeConflictDialogContent = () => + import('@/workbench/extensions/manager/components/manager/NodeConflictDialogContent.vue') +const lazyNodeConflictHeader = () => + import('@/workbench/extensions/manager/components/manager/NodeConflictHeader.vue') +const lazyNodeConflictFooter = () => + import('@/workbench/extensions/manager/components/manager/NodeConflictFooter.vue') + export type ConfirmationDialogType = | 'default' | 'overwrite' @@ -58,9 +81,19 @@ export interface ExecutionErrorDialogInput { export const useDialogService = () => { const dialogStore = useDialogStore() - function showLoadWorkflowWarning( + async function showLoadWorkflowWarning( props: ComponentAttrs ) { + const [ + { default: MissingNodesContent }, + { default: MissingNodesHeader }, + { default: MissingNodesFooter } + ] = await Promise.all([ + lazyMissingNodesContent(), + lazyMissingNodesHeader(), + lazyMissingNodesFooter() + ]) + dialogStore.showDialog({ key: 'global-missing-nodes', headerComponent: MissingNodesHeader, @@ -84,9 +117,10 @@ export const useDialogService = () => { }) } - function showMissingModelsWarning( + async function showMissingModelsWarning( props: ComponentAttrs ) { + const { default: MissingModelsWarning } = await lazyMissingModelsWarning() dialogStore.showDialog({ key: 'global-missing-models-warning', component: MissingModelsWarning, @@ -94,7 +128,7 @@ export const useDialogService = () => { }) } - function showSettingsDialog( + async function showSettingsDialog( panel?: | 'about' | 'keybinding' @@ -106,6 +140,14 @@ export const useDialogService = () => { | 'workspace' | 'secrets' ) { + const [ + { default: SettingDialogHeader }, + { default: SettingDialogContent } + ] = await Promise.all([ + lazySettingDialogHeader(), + lazySettingDialogContent() + ]) + const props = panel ? { props: { defaultPanel: panel } } : undefined dialogStore.showDialog({ @@ -116,7 +158,15 @@ export const useDialogService = () => { }) } - function showAboutDialog() { + async function showAboutDialog() { + const [ + { default: SettingDialogHeader }, + { default: SettingDialogContent } + ] = await Promise.all([ + lazySettingDialogHeader(), + lazySettingDialogContent() + ]) + dialogStore.showDialog({ key: 'global-settings', headerComponent: SettingDialogHeader, @@ -223,6 +273,9 @@ export const useDialogService = () => { async function showApiNodesSignInDialog( apiNodeNames: string[] ): Promise { + const [{ default: ApiNodesSignInContent }, { default: ComfyOrgHeader }] = + await Promise.all([lazyApiNodesSignInContent(), lazyComfyOrgHeader()]) + return new Promise((resolve) => { dialogStore.showDialog({ key: 'api-nodes-signin', @@ -245,6 +298,9 @@ export const useDialogService = () => { } async function showSignInDialog(): Promise { + const [{ default: SignInContent }, { default: ComfyOrgHeader }] = + await Promise.all([lazySignInContent(), lazyComfyOrgHeader()]) + return new Promise((resolve) => { dialogStore.showDialog({ key: 'global-signin', @@ -340,12 +396,15 @@ export const useDialogService = () => { }) } - function showTopUpCreditsDialog(options?: { + async function showTopUpCreditsDialog(options?: { isInsufficientCredits?: boolean }) { const { isActiveSubscription } = useSubscription() if (!isActiveSubscription.value) return + const { default: TopUpCreditsDialogContent } = + await lazyTopUpCreditsDialogContent() + return dialogStore.showDialog({ key: 'top-up-credits', component: TopUpCreditsDialogContent, @@ -364,7 +423,10 @@ export const useDialogService = () => { /** * Shows a dialog for updating the current user's password. */ - function showUpdatePasswordDialog() { + async function showUpdatePasswordDialog() { + const [{ default: UpdatePasswordContent }, { default: ComfyOrgHeader }] = + await Promise.all([lazyUpdatePasswordContent(), lazyComfyOrgHeader()]) + return dialogStore.showDialog({ key: 'global-update-password', component: UpdatePasswordContent, @@ -426,12 +488,22 @@ export const useDialogService = () => { }) } - function showImportFailedNodeDialog( + async function showImportFailedNodeDialog( options: { conflictedPackages?: ConflictDetectionResult[] dialogComponentProps?: DialogComponentProps } = {} ) { + const [ + { default: ImportFailedNodeHeader }, + { default: ImportFailedNodeFooter }, + { default: ImportFailedNodeContent } + ] = await Promise.all([ + lazyImportFailedNodeHeader(), + lazyImportFailedNodeFooter(), + lazyImportFailedNodeContent() + ]) + const { dialogComponentProps, conflictedPackages } = options return dialogStore.showDialog({ @@ -463,7 +535,7 @@ export const useDialogService = () => { }) } - function showNodeConflictDialog( + async function showNodeConflictDialog( options: { showAfterWhatsNew?: boolean conflictedPackages?: ConflictDetectionResult[] @@ -472,6 +544,16 @@ export const useDialogService = () => { onButtonClick?: () => void } = {} ) { + const [ + { default: NodeConflictHeader }, + { default: NodeConflictFooter }, + { default: NodeConflictDialogContent } + ] = await Promise.all([ + lazyNodeConflictHeader(), + lazyNodeConflictFooter(), + lazyNodeConflictDialogContent() + ]) + const { dialogComponentProps, buttonText, diff --git a/src/services/load3dService.ts b/src/services/load3dService.ts index 687fd421a..632331752 100644 --- a/src/services/load3dService.ts +++ b/src/services/load3dService.ts @@ -1,11 +1,76 @@ +/** + * Load3D Service - provides access to Load3D instances + * + * This service uses lazy imports to avoid pulling THREE.js into the main bundle. + * The nodeToLoad3dMap is accessed lazily - it will only be available after + * the load3d extension has been loaded. + */ import { toRaw } from 'vue' -import * as SkeletonUtils from 'three/examples/jsm/utils/SkeletonUtils' -import { nodeToLoad3dMap } from '@/composables/useLoad3d' -import { useLoad3dViewer } from '@/composables/useLoad3dViewer' import type Load3d from '@/extensions/core/load3d/Load3d' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema' +import type { Object3D } from 'three' + +// Type for the useLoad3dViewer composable function +// Using explicit type to avoid import() type annotations (lint rule) +type UseLoad3dViewerFn = (node?: LGraphNode) => { + initializeViewer: (containerRef: HTMLElement, source: Load3d) => Promise + initializeStandaloneViewer: ( + containerRef: HTMLElement, + modelUrl: string + ) => Promise + cleanup: () => void + handleResize: () => void + handleMouseEnter: () => void + handleMouseLeave: () => void + applyChanges: () => Promise + restoreInitialState: () => void + refreshViewport: () => void + exportModel: (format: string) => Promise + handleBackgroundImageUpdate: (file: File | null) => Promise + handleModelDrop: (file: File) => Promise + handleSeek: (progress: number) => void + needApplyChanges: { value: boolean } + [key: string]: unknown +} + +// Type for SkeletonUtils module +type SkeletonUtilsModule = { clone: (source: Object3D) => Object3D } + +// Cache for lazy-loaded modules +let cachedNodeToLoad3dMap: Map | null = null +let cachedUseLoad3dViewer: UseLoad3dViewerFn | null = null +let cachedSkeletonUtils: SkeletonUtilsModule | null = null + +// Sync accessor - returns null if module not yet loaded +function getNodeToLoad3dMapSync(): Map | null { + return cachedNodeToLoad3dMap +} + +// Async loader for nodeToLoad3dMap - also caches for sync access +async function loadNodeToLoad3dMap(): Promise> { + if (!cachedNodeToLoad3dMap) { + const module = await import('@/composables/useLoad3d') + cachedNodeToLoad3dMap = module.nodeToLoad3dMap + } + return cachedNodeToLoad3dMap +} + +async function loadUseLoad3dViewer() { + if (!cachedUseLoad3dViewer) { + const module = await import('@/composables/useLoad3dViewer') + cachedUseLoad3dViewer = module.useLoad3dViewer + } + return cachedUseLoad3dViewer +} + +async function loadSkeletonUtils() { + if (!cachedSkeletonUtils) { + cachedSkeletonUtils = await import('three/examples/jsm/utils/SkeletonUtils') + } + return cachedSkeletonUtils +} // Type definitions for Load3D node interface SceneConfig { @@ -30,14 +95,30 @@ export class Load3dService { return Load3dService.instance } + /** + * Get Load3d instance for a node (synchronous). + * Returns null if the load3d module hasn't been loaded yet. + */ getLoad3d(node: LGraphNode): Load3d | null { const rawNode = toRaw(node) + const map = getNodeToLoad3dMapSync() + if (!map) return null + return map.get(rawNode) || null + } - return nodeToLoad3dMap.get(rawNode) || null + /** + * Get Load3d instance for a node (async, loads module if needed). + */ + async getLoad3dAsync(node: LGraphNode): Promise { + const rawNode = toRaw(node) + const map = await loadNodeToLoad3dMap() + return map.get(rawNode) || null } getNodeByLoad3d(load3d: Load3d): LGraphNode | null { - for (const [node, instance] of nodeToLoad3dMap) { + const map = getNodeToLoad3dMapSync() + if (!map) return null + for (const [node, instance] of map) { if (instance === load3d) { return node } @@ -47,23 +128,44 @@ export class Load3dService { removeLoad3d(node: LGraphNode) { const rawNode = toRaw(node) + const map = getNodeToLoad3dMapSync() + if (!map) return - const instance = nodeToLoad3dMap.get(rawNode) + const instance = map.get(rawNode) if (instance) { instance.remove() - - nodeToLoad3dMap.delete(rawNode) + map.delete(rawNode) } } clear() { - for (const [node] of nodeToLoad3dMap) { + const map = getNodeToLoad3dMapSync() + if (!map) return + for (const [node] of map) { this.removeLoad3d(node) } } - getOrCreateViewer(node: LGraphNode) { + /** + * Get or create viewer (async, loads module if needed). + * Use this for initial viewer creation. + */ + async getOrCreateViewer(node: LGraphNode) { + if (!viewerInstances.has(node.id)) { + const useLoad3dViewer = await loadUseLoad3dViewer() + viewerInstances.set(node.id, useLoad3dViewer(node)) + } + + return viewerInstances.get(node.id) + } + + /** + * Get or create viewer (sync version). + * Only works after useLoad3dViewer has been loaded. + * Returns null if module not yet loaded - use async version instead. + */ + getOrCreateViewerSync(node: LGraphNode, useLoad3dViewer: UseLoad3dViewerFn) { if (!viewerInstances.has(node.id)) { viewerInstances.set(node.id, useLoad3dViewer(node)) } @@ -98,6 +200,7 @@ export class Load3dService { } } else { // Use SkeletonUtils.clone for proper skeletal animation support + const SkeletonUtils = await loadSkeletonUtils() const modelClone = SkeletonUtils.clone(sourceModel) target.getModelManager().currentModel = modelClone @@ -184,7 +287,7 @@ export class Load3dService { } async handleViewerClose(node: LGraphNode) { - const viewer = useLoad3dService().getOrCreateViewer(node) + const viewer = await useLoad3dService().getOrCreateViewer(node) if (viewer.needApplyChanges.value) { await viewer.applyChanges() diff --git a/src/workbench/extensions/manager/composables/useImportFailedDetection.ts b/src/workbench/extensions/manager/composables/useImportFailedDetection.ts index 1edc65006..d125255c8 100644 --- a/src/workbench/extensions/manager/composables/useImportFailedDetection.ts +++ b/src/workbench/extensions/manager/composables/useImportFailedDetection.ts @@ -33,7 +33,7 @@ function createImportFailedDialog() { onClose?: () => void ) => { if (conflictedPackages && conflictedPackages.length > 0) { - showImportFailedNodeDialog({ + void showImportFailedNodeDialog({ conflictedPackages, dialogComponentProps: { onClose diff --git a/src/workbench/extensions/manager/composables/useManagerState.ts b/src/workbench/extensions/manager/composables/useManagerState.ts index 49c9debfd..3a161b76f 100644 --- a/src/workbench/extensions/manager/composables/useManagerState.ts +++ b/src/workbench/extensions/manager/composables/useManagerState.ts @@ -153,7 +153,7 @@ export function useManagerState() { switch (state) { case ManagerUIState.DISABLED: - dialogService.showSettingsDialog('extension') + void dialogService.showSettingsDialog('extension') break case ManagerUIState.LEGACY_UI: { @@ -173,7 +173,7 @@ export function useManagerState() { } // Fallback to extensions panel if not showing toast if (options?.showToastOnLegacyError === false) { - dialogService.showSettingsDialog('extension') + void dialogService.showSettingsDialog('extension') } } break diff --git a/vite.config.mts b/vite.config.mts index 0732698cb..bf5ed3b5c 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -419,6 +419,31 @@ export default defineConfig({ minify: SHOULD_MINIFY, target: 'es2022', sourcemap: GENERATE_SOURCEMAP, + // Exclude heavy optional vendor chunks from initial module preload + // These chunks are only needed when their features are used (3D, terminal, etc.) + modulePreload: { + resolveDependencies: (_filename, deps, { hostType }) => { + // Only filter for HTML entry points, not for dynamic imports + if (hostType !== 'html') return deps + + // Exclude heavy vendor chunks that should be lazy-loaded + // - vendor-three: 3D preview (Load3D nodes) + // - vendor-xterm: Terminal emulator (logs panel) + // - vendor-tiptap: Rich text editor (markdown widgets) + // - vendor-chart: Chart.js (stats/monitoring) + // - vendor-yjs: CRDT library (layout store, loaded on first graph) + const lazyVendors = [ + 'vendor-three', + 'vendor-xterm', + 'vendor-tiptap', + 'vendor-chart', + 'vendor-yjs' + ] + return deps.filter( + (dep) => !lazyVendors.some((vendor) => dep.includes(vendor)) + ) + } + }, rolldownOptions: { treeshake: { manualPureFunctions: [ @@ -442,52 +467,100 @@ export default defineConfig({ 'console.trace' ] }, - experimental: { - strictExecutionOrder: true - }, output: { keepNames: true, codeSplitting: { groups: [ + // Framework core - highest priority, very stable + { + name: 'vendor-vue-core', + test: /[\\/]node_modules[\\/](vue|@vue|pinia|vue-router)[\\/]/, + priority: 20 + }, + + { + name: 'vendor-firebase', + test: /[\\/]node_modules[\\/](@?firebase|@firebase)[\\/]/, + priority: 15 + }, + { + name: 'vendor-sentry', + test: /[\\/]node_modules[\\/]@sentry[\\/]/, + priority: 15 + }, + + // UI component libraries { name: 'vendor-primevue', test: /[\\/]node_modules[\\/](@?primevue|@primeuix)[\\/]/, - priority: 10 - }, - { - name: 'vendor-tiptap', - test: /[\\/]node_modules[\\/]@tiptap[\\/]/, - priority: 10 - }, - { - name: 'vendor-chart', - test: /[\\/]node_modules[\\/]chart\.js[\\/]/, - priority: 10 - }, - { - name: 'vendor-three', - test: /[\\/]node_modules[\\/](three|@sparkjsdev)[\\/]/, - priority: 10 - }, - { - name: 'vendor-xterm', - test: /[\\/]node_modules[\\/]@xterm[\\/]/, - priority: 10 - }, - { - name: 'vendor-vue', - test: /[\\/]node_modules[\\/](vue|pinia)[\\/]/, - priority: 10 + priority: 15 }, { name: 'vendor-reka-ui', test: /[\\/]node_modules[\\/]reka-ui[\\/]/, - priority: 10 + priority: 15 }, + + // Heavy optional features + { + name: 'vendor-three', + test: /[\\/]node_modules[\\/](three|@sparkjsdev)[\\/]/, + priority: 15 + }, + { + name: 'vendor-tiptap', + test: /[\\/]node_modules[\\/]@tiptap[\\/]/, + priority: 15 + }, + { + name: 'vendor-chart', + test: /[\\/]node_modules[\\/]chart\.js[\\/]/, + priority: 15 + }, + { + name: 'vendor-xterm', + test: /[\\/]node_modules[\\/]@xterm[\\/]/, + priority: 15 + }, + { + name: 'vendor-yjs', + test: /[\\/]node_modules[\\/](yjs|lib0)[\\/]/, + priority: 15 + }, + + // Utilities and validation + { + name: 'vendor-vueuse', + test: /[\\/]node_modules[\\/]@vueuse[\\/]/, + priority: 12 + }, + { + name: 'vendor-i18n', + test: /[\\/]node_modules[\\/](vue-i18n|@intlify)[\\/]/, + priority: 12 + }, + { + name: 'vendor-zod', + test: /[\\/]node_modules[\\/](zod|zod-validation-error)[\\/]/, + priority: 12 + }, + { + name: 'vendor-axios', + test: /[\\/]node_modules[\\/]axios[\\/]/, + priority: 12 + }, + { + name: 'vendor-markdown', + test: /[\\/]node_modules[\\/](marked|dompurify)[\\/]/, + priority: 12 + }, + + // Catch-all for remaining node_modules { name: 'vendor-other', test: /[\\/]node_modules[\\/]/, - priority: 0 + priority: 0, + minSize: 10000 } ] }