From cbdc7d030f15e2f279bc869c5bb701e51e5cdc32 Mon Sep 17 00:00:00 2001 From: Alexander Brown Date: Mon, 2 Feb 2026 19:05:28 -0800 Subject: [PATCH] feat: code splitting optimization - reduce initial bundle by 36% (#8542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Reduces initial module preload from 12.94 MB to 8.24 MB (-4.7 MB, -36%). ## Changes - Split vendor chunks for better cache isolation (firebase, sentry, i18n, zod, etc.) - Exclude heavy optional chunks from initial preload: THREE.js, xterm, tiptap, chart.js, yjs - Lazy load 16 dialog components in dialogService - Add \ endor-yjs\ chunk for CRDT library ## Metrics | Metric | Before | After | |--------|--------|-------| | Initial preload | 12.94 MB | 8.24 MB | | vendor-other | 4,006 KB | 2,156 KB | | dialogService | 1,860 KB | 1,304 KB | All excluded chunks still load on-demand when their features are used. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8542-feat-code-splitting-optimization-reduce-initial-bundle-by-36-2fb6d73d36508146aaf7fdaed3274033) by [Unito](https://www.unito.io) --------- Co-authored-by: Amp Co-authored-by: github-actions --- ...le-settings-dialog-mobile-chrome-linux.png | Bin 16869 -> 16862 bytes src/components/load3d/Load3dViewerContent.vue | 3 +- .../sidebar/tabs/AssetsSidebarTab.vue | 4 +- src/composables/useCoreCommands.ts | 4 +- src/composables/useHelpCenter.ts | 2 +- src/composables/useLoad3dDrag.ts | 2 +- src/extensions/core/index.ts | 6 +- src/extensions/core/load3d/constants.ts | 16 +++ src/extensions/core/load3d/interfaces.ts | 30 ++-- src/extensions/core/load3dLazy.ts | 53 +++++++ .../composables/useSubscriptionActions.ts | 2 +- .../extensions/linearMode/LinearPreview.vue | 3 +- src/services/dialogService.ts | 132 +++++++++++++---- src/services/load3dService.ts | 125 ++++++++++++++-- .../composables/useImportFailedDetection.ts | 2 +- .../manager/composables/useManagerState.ts | 4 +- vite.config.mts | 135 ++++++++++++++---- 17 files changed, 423 insertions(+), 100 deletions(-) create mode 100644 src/extensions/core/load3d/constants.ts create mode 100644 src/extensions/core/load3dLazy.ts 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 6bb4ac6e39785c1f46c8d9945339c1a2c268af28..17de9b03aff4c13f05c805b3badb77cd151c286b 100644 GIT binary patch literal 16862 zcmeHvWk6Nyx-Q5Pmr_emQPPEUDxG2>4GPjF9g~pmP*eozMp{a`djcxmFzHr$Qj_ku zFMF+h&)VnibAFt&e%@aa&N0Uv78Vwjm4#NAVRgt#C8ecBu&QcEo6IB#`|`x| zDEdZ-*v=J|6&3Yk8F2c>1_YGa>Cu(Y`{ibGOUXx9Tu_rFW~k)qy**%|5R=B0gWaN@ zeknQf))>J9`d$VX#j#II{E-5>x2vNi5`7yu-&`#6@m19VlR&r6cw%0u5l4@-h86qGSft$rkE;m zRniLnCxc9MfoUH-!B@NwfgW+whyHF-g=)n{iSD~2qos)EAZlt75`9%Gef_?2tEtyG zmqy3N@>L5TwM*K}waxwb@oH;cmQA|F>T6ru(&@<&*-K?*=Pkk1iihm;=@qrFetw_w zJUe+$K;9HcNlr~|g_NXP8!c@g8L1Tecw>1eKPWJ8Fo8inL0BP1L_|c#b+y#(U{msx zTGS)Q@Pfc98IKu-sK>Y+mv2mUb#+{j8^^OGFFQFZVW*_D#HgsKk00C9q@%cRKi1Qm zR?tjbL(@s7yfxdLZO!C~<}~!%jubsyytC>|K}k8~yg;c|0p~N<9+mcFC|5(p=CdIQ z0)Y^q7RZJZNxbi_?s0{hnwp4+sLz$htY5;RKXfVUbmoKiC8D*-8V#Hdwuzc)xZdyH zy*oMCo66*I+Fwsbs%9(a3R;YE_xXeh(ogj3HwU7LIJA*BNl6(ZcGo7V56PH@4z?De z7OHIKq|D7%`nbx<*k61J&WU1TAJNL+N|TBRHB`%_7O$=Rs+v51GA64D7>44?Z zMz6-fV7i})tyM+B!a*-WOfTWBUTS9AeTPFFog?LeFY+@*Jcz7pFh@-hWwW!S#K%{@ zIoEDAiB$W{a?c4zQ@;d1UqY@nP+uu`^i>(RSY+twh~ZkC@>)COpT$0s z$**9m;}WxQ7&5t}k6mL?mdSd%D;&z*>J@%_fp)GfB5ZO7wk7R#Vg|H`l*o`qTM=^2 z@L>_HtK{?0hbbPNQbnl@%5pNEvB7!i$mtzJ=SHiEDkW*Z^0zv~U-F}vjWvnw6$18F z#~4DwG$qw({oq`^gLTV1=tDgP?wu5bI>)4=P!BvPMDiZFkZdAtCW@b{ONu{Wx0KGBqkMvM$~56iKtvSffrd3`i2S67<2WgYRa@_4U~O1eD~gBNolczX#4+nFdduNqlt^3^jTr@HW>Vp4B&*uy_+!bdVG8CFwNJ#%K6XqmwkrfCtm;ZRzBoj2 zO5SO2vVGKKpi@J+LMv$5(#kxcE)Q)pD$3eJDY-Anry5?bp)@1Uw z8siMw+S$raf<}WyIfqeSYd%hwbwb5)_99!@R|TV^on>QT%xAg?LbZ{khdAhw^fVRL z__6Xy7wN<7!^h46ZDj7^Oo&kK$FgDBtVNR2WN5Ve^UO$vkF=uQHw4{vYDn4eYHMo` z2ci~2n>L@IGI@{rH?M^{ILn8umArU_E?734YG!k9yjbXNIRz26?NJYa;NB{FnY4aHGqiaNQz6~lH?~OUJJJ2!}Oezg^*D8ZaNWL zXQAM)Ep8F`u{*>J@(iMx$4C@gRHD{*))X8y4(G_b?Xhp-5<2kPw^dILcM@00grz+M zV{Sw;wsA>yKRU!Kx-A~IjIw#IBBweTO-RZDWTkK{gkU zK%>%{x;Ghp->WE@C9-rm>B(;G?O_IUV>R^-HkzqJ3i>j5vp2_6DE}Q@I%iM9qJ=QheSMvY zNlDu;C3?x|=_aeGsf`WKy6kqFFz7QEZfsch_V(V|7m~z6iM8pPhcd`hjg5?`siz+{ zFUy`x#rqKXvIM8mT8CEqAD^kB@udR>ungE*<2Nj5LM3?$IUV5JOC)G%dy1o#2|X+W z0uuBLMi^Tl(0AKfs1$KWU_o5QdLqKY z%*r{z$Cp{z+}$Q!R!fJtu{iv-?($=sBzuf_VFR4%u~ToqKTFt_hl+( zw4~cEbhV}@KlTxO^5iDQb*bN!!0W2*;P7J~Ldk3AL~bS`UdS;N<9S@DQ!B*J?{<1% ze16qW*BgApvQ+Y&&raRZL}v#JS7WHzpMP%fyPkZgqB?BJz>raF+--*CA6?wk_Ta^) zfPNDUHW6#PjJtD`Wp9PRB_`!l@_RF|?e zD`Pkf%^rNM+rB6+-u$!PM=4WbZvAKd(H?aV)^XIk;$jUya-e(JX*wY^Vn&@;B9oSg z4C&DeiJs_*qvGi3o9?n1a+HR|su*FNN}HYXDG&0yFO#^#oa7Vl=W104?HKV{jxY9S zeoWY^ajm~|=gwF;<_mVd|M3A{YQ4w2U-o~BzmFrQ=N@lI0 zVn@3tKQU8>8#B#^#erly^>Cw#?U&17XCUSkkxqr0l{TGm0=A8Q&j_3hRr!Ug(<**W z)>Nwt%EEmF9TmZ>RO_%>rJEKJG4Qkg(w#d&p4-py++{0m=8jJ^VAUM7MPv&(tV9br zCM0jHjQrjlC`JVmXKG56m)B@8M(EDuTBjg=m$qJ~Qmj_%pNbdQaVp-?t^ zYwIpMOFZVoue#zY3CNJu7Gt|ZIvgUVs0Q&Ms*}z3{7XbsZtFi@)AG{LOmEJy0J#do zEmE5NHC-+~oqA~|XiWLbdV0SDfs=1^ZWHKdVW(G~!vz=|9PwC_GDoY5Uy2^$h}PU} zW8`C8oB~+WD0un0cA&TLWuxCDCwpmmxv1S@QoL3|q;|pLKfM4{TKi&se?ftkN}gsf zVn@SDX{5{|``Q2xVsJ?uXpX*&OouCmr=OaNf4zBQhC#jDG6nmJRBqEJi@f39-sg|V zDNo9Md8w$V6vP;0KbbF9%O&2Q*+v5iz!;m5P*LSADAp#5xEf(5-QTOx`DTZODVkl< zDKRPRaH_1}M@U8>Wnwt%jyFHzA~icARWB!Z@Lp%x{p8Er)#Ynl_3G!FjkfkE4)TS& z_wRQ(Wp5`2M9s~{Bzj>lK{vZ&(Zx9e4-c=e<6&D)xnm>oEy~92qy`4^Snc z?5$=G9(>(d=G#W_a#+HEvzh(<@%xqW$}QmeWb3GVTMK@!d#5tEC6q!ACS$v3+ErMS zt~-a@YZDn<-TKX^jP~|FzG83E5zQ$)gC&lcs>_dQoo?yppf2%Zr|*zF_KJG~x0Oc)b!j zIuI`-Y3>lhB6GC6IssF?JE4aAfnkh?2=7>~PTlV2+{fKrhjh{J*o>=l2WN%EFJLSY zkn=V*Hky8wUP@cKj>8d~#8Lt&rmyfLsJF@{-x^|SAj}$4;VKmAu z;Tr2sA`iN$evVnyg-qH5{BBqH0ZzDYG)7@+kJd60Jw7uiC(I2Owp3J90I&dOK+LvK z=j7z5mzX@S^QZxAp`@e~@i=~KzdT4TCrL&v;?h4=7m<=Olc%K%V!z5|=hH^mV5XvY zSygpZc;ua@{z&(uGF*df)+$V3a+ve_k8dz5q?Q~ola#ldHKnV%VSxWwtnl&^uKx!-^G;5JjLGq<7EfQ*ZYQ^(eO3B#R zXG=pEgnKsa_*k{Vix=UMPGe*Dh~eg4nLs)#;uOjAPK|X942bzn!q$bG6vO46wkRn& zSP`?WPUq3v>q$Q7b}&bZ${(y#&et|orKX~?tgy|YuwDQ0yNl+;_&6Eww(I7sZ)|LK zvbCyeG?hvrtU6FGL%GWcH%5^>b(DKH8ylN+Bx^GE2y<+HvsAa@IU^LW^&cW-Q}5qT z#=mXYubwlEc+bPR#d>0<_61fD8kO*VXJg~6qEHL4VbXIdUpaS_oxMwe3aI(^WYHRc zQ&Yg4F5D}8qM2zug7(87BW-@|LoG%l$He}Da>$3(9TMlXF+b{Kd8_)`Sb28^*6Hby z!a?Xe9)z-*hDMsxIvAFn(f4FshQjD&Vcl(~!R9ur#rOlG+_d6cj&^0R zU2!fEnmiyhnkQq)KcbfRja~_D1uE-zjcGjjv1_IWcyzIxhFKkZEp)fkf;X0c zSZ2M*wAo+(d2)1imFjY_=1$Cg_fPrx7=ROlfNEE!Tb-vnL!C)UN!R3`64Fc=67%1F zxeoT*>~wC*jObdcT0v5Z3_Wnm$5P8)*f<8y!1;Ya(DJP#ZkR^Qk5#kct5at)b$MTP zJ}z)$)8T@jC8ml*6#R^LI-aLon3$LV9YCW~r1W_pXXH}rbk_2~G6_=)@eT-%mU>`L z?5YLN=o7=VHt+F!99L|YUn01&zg|yNn%ZGCReJ{z@gNx8%~$TukBuc6Z-{D_Eqg6pNuWjko{KQXnO^1hvRn^r&H}4fmMYO?s8^VeXIzX(z&|DhIS1B{E1f}=> z?OXht>+!mMf0DufgnXRi*{E&h=Y@Lj&Mn@oS@-3^To}Y1v3xo@R)&THpT+Q;QU8jc z)LiWBdD=BqfaGKXo0s|mf+z&4jJvf3upRe8i&BsnxR@GK>Uo+1Y8+ONfeK7jSoeM6 zK>#f4A2xX5w|^!>fZtzXodWWTftJ>M5-CPSb8|rhPGqZew}vsGW3!ZZMk=h`_tqwW*D#5WcEoTQbwrKD!=l&h1SizC?|G`0%0D{pcEp_VM?3c$x7x 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 literal 16869 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 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 } ] }