feat: code splitting optimization - reduce initial bundle by 36% (#8542)

## 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 <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Alexander Brown
2026-02-02 19:05:28 -08:00
committed by GitHub
parent 21492ecca5
commit cbdc7d030f
17 changed files with 423 additions and 100 deletions

View File

@@ -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<typeof MissingNodesContent>
) {
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<typeof MissingModelsWarning>
) {
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<boolean> {
const [{ default: ApiNodesSignInContent }, { default: ComfyOrgHeader }] =
await Promise.all([lazyApiNodesSignInContent(), lazyComfyOrgHeader()])
return new Promise<boolean>((resolve) => {
dialogStore.showDialog({
key: 'api-nodes-signin',
@@ -245,6 +298,9 @@ export const useDialogService = () => {
}
async function showSignInDialog(): Promise<boolean> {
const [{ default: SignInContent }, { default: ComfyOrgHeader }] =
await Promise.all([lazySignInContent(), lazyComfyOrgHeader()])
return new Promise<boolean>((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,

View File

@@ -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<void>
initializeStandaloneViewer: (
containerRef: HTMLElement,
modelUrl: string
) => Promise<void>
cleanup: () => void
handleResize: () => void
handleMouseEnter: () => void
handleMouseLeave: () => void
applyChanges: () => Promise<boolean>
restoreInitialState: () => void
refreshViewport: () => void
exportModel: (format: string) => Promise<void>
handleBackgroundImageUpdate: (file: File | null) => Promise<void>
handleModelDrop: (file: File) => Promise<void>
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<LGraphNode, Load3d> | null = null
let cachedUseLoad3dViewer: UseLoad3dViewerFn | null = null
let cachedSkeletonUtils: SkeletonUtilsModule | null = null
// Sync accessor - returns null if module not yet loaded
function getNodeToLoad3dMapSync(): Map<LGraphNode, Load3d> | null {
return cachedNodeToLoad3dMap
}
// Async loader for nodeToLoad3dMap - also caches for sync access
async function loadNodeToLoad3dMap(): Promise<Map<LGraphNode, Load3d>> {
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<Load3d | null> {
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()