diff --git a/apps/desktop/package.json b/apps/desktop/package.json index bc8ce85345..57362a0936 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -107,7 +107,7 @@ "typescript": "^5.9.3", "undici": "^7.16.0", "uuid": "^14.0.0", - "vite": "8.0.12", + "vite": "8.0.14", "vitest": "^3.2.4", "zod": "^3.25.76" }, diff --git a/package.json b/package.json index 2264eddb19..cccc5c03b1 100644 --- a/package.json +++ b/package.json @@ -489,7 +489,8 @@ "@types/ws": "^8.18.1", "@types/xast": "^2.0.4", "@typescript/native-preview": "7.0.0-dev.20260425.1", - "@vitejs/plugin-react": "^5.1.4", + "@vitejs/devtools": "0.2.0", + "@vitejs/plugin-react": "^6.0.2", "@vitest/coverage-v8": "^3.2.4", "ajv": "^8.17.1", "ajv-keywords": "^5.1.0", @@ -536,7 +537,7 @@ "typescript": "^5.9.3", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", - "vite": "8.0.12", + "vite": "8.0.14", "vite-plugin-pwa": "^1.2.0", "vite-tsconfig-paths": "^6.1.1", "vitest": "^3.2.4" diff --git a/plugins/vite/routeChunkPreload.test.ts b/plugins/vite/routeChunkPreload.test.ts new file mode 100644 index 0000000000..9912cf4916 --- /dev/null +++ b/plugins/vite/routeChunkPreload.test.ts @@ -0,0 +1,461 @@ +import { describe, expect, it } from 'vitest'; + +import { __testing, routeChunkPreload } from './routeChunkPreload'; + +interface TestOutputChunk { + code: string; + dynamicImports: string[]; + facadeModuleId: null | string; + fileName: string; + imports: string[]; + moduleIds: string[]; + type: 'chunk'; +} + +type TestOutputBundle = Record; + +function createChunk(overrides: Partial): TestOutputChunk { + return { + code: '', + dynamicImports: [], + facadeModuleId: null, + fileName: 'assets/chunk.js', + imports: [], + moduleIds: [], + type: 'chunk', + ...overrides, + }; +} + +describe('routeChunkPreload', () => { + it('creates route preload entries from emitted route chunk filenames', () => { + const bundle = { + 'assets/agent-CJm8x.js': createChunk({ + dynamicImports: ['assets/MainChatInput-BwuHC6qv.js', 'assets/typescript-D20RI-Hp.js'], + facadeModuleId: '/repo/src/routes/(main)/agent/index.desktop.tsx', + fileName: 'assets/agent-CJm8x.js', + imports: ['vendor/vendor-icons-Bd7x.js'], + moduleIds: ['/repo/src/routes/(main)/agent/index.desktop.tsx'], + }), + 'vendor/vendor-icons-Bd7x.js': createChunk({ + facadeModuleId: null, + fileName: 'vendor/vendor-icons-Bd7x.js', + moduleIds: ['/repo/node_modules/lucide-react/dist/esm/icons/settings.js'], + }), + 'assets/MainChatInput-BwuHC6qv.js': createChunk({ + fileName: 'assets/MainChatInput-BwuHC6qv.js', + moduleIds: ['/repo/src/routes/(main)/agent/features/Conversation/MainChatInput/index.tsx'], + }), + 'assets/typescript-D20RI-Hp.js': createChunk({ + fileName: 'assets/typescript-D20RI-Hp.js', + moduleIds: ['/repo/node_modules/@shikijs/langs/dist/typescript.mjs'], + }), + } satisfies TestOutputBundle; + + const manifest = __testing.createRoutePreloadManifest(bundle, '/repo'); + const agentEntry = manifest.find((entry) => entry.id === 'desktop-chat-launch'); + + expect(agentEntry?.preload).toEqual([ + 'assets/agent-CJm8x.js', + 'vendor/vendor-icons-Bd7x.js', + 'assets/MainChatInput-BwuHC6qv.js', + ]); + }); + + it('matches route modules when built from the cloud repository root', () => { + const bundle = { + 'assets/agent-CJm8x.js': createChunk({ + facadeModuleId: '/repo/lobehub/src/routes/(main)/agent/index.tsx', + fileName: 'assets/agent-CJm8x.js', + moduleIds: ['/repo/lobehub/src/routes/(main)/agent/index.tsx'], + }), + } satisfies TestOutputBundle; + + const manifest = __testing.createRoutePreloadManifest(bundle, '/repo'); + const agentEntry = manifest.find((entry) => entry.id === 'desktop-chat-launch'); + + expect(agentEntry?.preload).toEqual(['assets/agent-CJm8x.js']); + }); + + it('matches non-index platform-specific route module variants', () => { + const bundle = { + 'assets/settings-provider-CJm8x.js': createChunk({ + facadeModuleId: '/repo/src/routes/(main)/settings/provider.desktop.tsx', + fileName: 'assets/settings-provider-CJm8x.js', + moduleIds: ['/repo/src/routes/(main)/settings/provider.desktop.tsx'], + }), + } satisfies TestOutputBundle; + + const manifest = __testing.createRoutePreloadManifest(bundle, '/repo', [ + { + id: 'custom-settings-provider', + modules: ['src/routes/(main)/settings/provider'], + patterns: ['^/settings/provider(/|$)'], + }, + ]); + + expect(manifest[0]?.preload).toEqual(['assets/settings-provider-CJm8x.js']); + }); + + it('can include static imports for explicitly configured groups', () => { + const bundle = { + 'assets/agent-CJm8x.js': createChunk({ + facadeModuleId: '/repo/src/routes/(main)/agent/index.tsx', + fileName: 'assets/agent-CJm8x.js', + imports: ['vendor/vendor-icons-Bd7x.js'], + moduleIds: ['/repo/src/routes/(main)/agent/index.tsx'], + }), + 'vendor/vendor-icons-Bd7x.js': createChunk({ + facadeModuleId: null, + fileName: 'vendor/vendor-icons-Bd7x.js', + moduleIds: ['/repo/node_modules/lucide-react/dist/esm/icons/settings.js'], + }), + } satisfies TestOutputBundle; + + const manifest = __testing.createRoutePreloadManifest(bundle, '/repo', [ + { + id: 'custom-agent', + includeStaticImports: true, + modules: ['src/routes/(main)/agent'], + patterns: ['^/agent(/|$)'], + }, + ]); + + expect(manifest[0]?.preload).toEqual(['assets/agent-CJm8x.js', 'vendor/vendor-icons-Bd7x.js']); + }); + + it('can include dynamic imports for route warmup groups', () => { + const bundle = { + 'assets/settings-CJm8x.js': createChunk({ + dynamicImports: [ + 'assets/settings-provider-D8p.js', + 'assets/typescript-D20RI-Hp.js', + 'assets/pierre-dark-BVeDunhK.js', + 'assets/mermaid.core-FQG0m7QG.js', + ], + facadeModuleId: '/repo/src/routes/(main)/settings/index.tsx', + fileName: 'assets/settings-CJm8x.js', + imports: ['vendor/vendor-icons-Bd7x.js'], + moduleIds: ['/repo/src/routes/(main)/settings/index.tsx'], + }), + 'assets/settings-provider-D8p.js': createChunk({ + fileName: 'assets/settings-provider-D8p.js', + moduleIds: ['/repo/src/routes/(main)/settings/provider/index.tsx'], + }), + 'assets/typescript-D20RI-Hp.js': createChunk({ + fileName: 'assets/typescript-D20RI-Hp.js', + moduleIds: ['/repo/node_modules/@shikijs/langs/dist/typescript.mjs'], + }), + 'assets/pierre-dark-BVeDunhK.js': createChunk({ + fileName: 'assets/pierre-dark-BVeDunhK.js', + }), + 'assets/mermaid.core-FQG0m7QG.js': createChunk({ + imports: ['assets/cytoscape.esm-B2pAKChx.js'], + fileName: 'assets/mermaid.core-FQG0m7QG.js', + }), + 'assets/cytoscape.esm-B2pAKChx.js': createChunk({ + fileName: 'assets/cytoscape.esm-B2pAKChx.js', + }), + 'assets/graphlib-s-2OPgNI.js': createChunk({ + fileName: 'assets/graphlib-s-2OPgNI.js', + moduleIds: ['/repo/node_modules/graphlib/index.js'], + }), + 'vendor/vendor-icons-Bd7x.js': createChunk({ + fileName: 'vendor/vendor-icons-Bd7x.js', + moduleIds: ['/repo/node_modules/lucide-react/dist/esm/icons/settings.js'], + }), + } satisfies TestOutputBundle; + + const manifest = __testing.createRoutePreloadManifest(bundle, '/repo', [ + { + id: 'custom-settings', + includeDynamicImports: true, + includeStaticImports: true, + modules: ['src/routes/(main)/settings'], + patterns: ['^/settings(/|$)'], + }, + ]); + + expect(manifest[0]?.preload).toEqual([ + 'assets/settings-CJm8x.js', + 'vendor/vendor-icons-Bd7x.js', + 'assets/settings-provider-D8p.js', + ]); + }); + + it('keeps low-probability routes out of the default preload manifest', () => { + const bundle = { + 'assets/settings-CJm8x.js': createChunk({ + facadeModuleId: '/repo/src/routes/(main)/settings/index.tsx', + fileName: 'assets/settings-CJm8x.js', + moduleIds: ['/repo/src/routes/(main)/settings/index.tsx'], + }), + } satisfies TestOutputBundle; + + expect(__testing.createRoutePreloadManifest(bundle, '/repo')).toEqual([]); + }); + + it('creates a sorted all-JS warmup manifest from emitted chunks', () => { + const bundle = { + 'assets/agent-CJm8x.js': createChunk({ + fileName: 'assets/agent-CJm8x.js', + }), + 'assets/i18n-en-US-DjOrYbGM.js': createChunk({ + fileName: 'assets/i18n-en-US-DjOrYbGM.js', + }), + 'assets/style-D8p.css': createChunk({ + fileName: 'assets/style-D8p.css', + }), + 'i18n/i18n-default-BV0oTRYH.js': createChunk({ + fileName: 'i18n/i18n-default-BV0oTRYH.js', + }), + 'assets/javascript-C1Q1DjBS.js': createChunk({ + fileName: 'assets/javascript-C1Q1DjBS.js', + moduleIds: ['/repo/node_modules/@shikijs/langs/dist/javascript.mjs'], + }), + 'assets/github-dark-Bo88FFvI.js': createChunk({ + fileName: 'assets/github-dark-Bo88FFvI.js', + moduleIds: ['/repo/node_modules/@shikijs/themes/dist/github-dark.mjs'], + }), + 'assets/wasm-CGWTL0IK.js': createChunk({ + fileName: 'assets/wasm-CGWTL0IK.js', + moduleIds: ['/repo/node_modules/oniguruma-to-es/dist/wasm.mjs'], + }), + 'assets/pierre-light-Dmd9-PaL.js': createChunk({ + fileName: 'assets/pierre-light-Dmd9-PaL.js', + }), + 'assets/mermaid-parser.core-BLfQKdsC.js': createChunk({ + fileName: 'assets/mermaid-parser.core-BLfQKdsC.js', + }), + 'assets/rough.esm-BgK9YCbF.js': createChunk({ + fileName: 'assets/rough.esm-BgK9YCbF.js', + moduleIds: ['/repo/node_modules/roughjs/bin/rough.js'], + }), + 'vendor/vendor-icons-Bd7x.js': createChunk({ + fileName: 'vendor/vendor-icons-Bd7x.js', + }), + 'assets/image.png': { type: 'asset' }, + } satisfies TestOutputBundle; + + expect(__testing.createAllJsWarmupManifest(bundle)).toEqual([ + 'assets/agent-CJm8x.js', + 'vendor/vendor-icons-Bd7x.js', + ]); + }); + + it('injects route modulepreload links into html and skips existing module assets', () => { + const html = [ + '', + ' ', + ' ', + ' ', + ' ', + '', + ].join('\n'); + + const result = __testing.injectRouteModulepreloadsIntoHtml( + html, + [{ id: 'desktop-page', patterns: ['^/page(/|$)'], preload: ['assets/page-B9kLm.js', 'assets/existing-B2.js'] }], + '/_spa/', + 'dpl_test', + ); + + expect(result).toContain(''); + expect(result.match(/assets\/existing-B2\.js/g)).toHaveLength(1); + expect(result.match(/assets\/index-D8p\.js/g)).toHaveLength(1); + }); + + it('removes small existing modulepreload links from html', () => { + const html = [ + '', + ' ', + ' ', + ' ', + ' ', + '', + ].join('\n'); + + const result = __testing.removeSmallModulepreloadsFromHtml( + html, + '/_spa/', + (fileName) => fileName === 'assets/large-D8p.js', + ); + + expect(result).not.toContain('/_spa/assets/small-D8p.js'); + expect(result).toContain('/_spa/assets/large-D8p.js?dpl=dpl_test'); + }); + + it('appends the deployment query to emitted preload assets', () => { + expect(__testing.createAssetHref('assets/page-B9kLm.js', '/_spa/', 'dpl_test')).toBe( + '/_spa/assets/page-B9kLm.js?dpl=dpl_test', + ); + expect(__testing.createAssetHref('assets/page-B9kLm.js?dpl=dpl_test', '/_spa/', 'dpl_test')).toBe( + '/_spa/assets/page-B9kLm.js?dpl=dpl_test', + ); + }); + + it('injects emitted route preloads into html with the Vite html transform hook', () => { + const plugin = routeChunkPreload({ allJsWarmup: true }); + const configResolved = plugin.configResolved as (config: { base: string; root: string }) => void; + const bundle = { + 'assets/agent-CJm8x.js': createChunk({ + code: 'x'.repeat(2048), + facadeModuleId: '/repo/src/routes/(main)/agent/index.tsx', + fileName: 'assets/agent-CJm8x.js', + moduleIds: ['/repo/src/routes/(main)/agent/index.tsx'], + }), + 'assets/settings-D8p.js': createChunk({ + code: 'x'.repeat(2048), + facadeModuleId: '/repo/src/routes/(main)/settings/index.tsx', + fileName: 'assets/settings-D8p.js', + moduleIds: ['/repo/src/routes/(main)/settings/index.tsx'], + }), + } satisfies TestOutputBundle; + const transformIndexHtml = plugin.transformIndexHtml as { + handler: (html: string, ctx: { bundle: TestOutputBundle }) => string; + }; + + configResolved({ base: '/_spa/', root: '/repo' }); + const result = transformIndexHtml.handler( + '', + { bundle }, + ); + + expect(result).toContain('/_spa/assets/agent-CJm8x.js'); + expect(result).toContain('/_spa/assets/settings-D8p.js'); + expect(result).toContain('/_spa/assets/js-warmup-manifest.json'); + expect(result).toContain('rel="modulepreload"'); + expect(result).not.toContain('window.__LOBE_PRELOAD_ROUTE__'); + expect(result).not.toContain("import('@/routes"); + }); + + it('does not inject the all-JS warmup manifest by default', () => { + const plugin = routeChunkPreload(); + const configResolved = plugin.configResolved as (config: { base: string; root: string }) => void; + const bundle = { + 'assets/agent-CJm8x.js': createChunk({ + code: 'x'.repeat(2048), + facadeModuleId: '/repo/src/routes/(main)/agent/index.tsx', + fileName: 'assets/agent-CJm8x.js', + moduleIds: ['/repo/src/routes/(main)/agent/index.tsx'], + }), + 'assets/settings-D8p.js': createChunk({ + code: 'x'.repeat(2048), + facadeModuleId: '/repo/src/routes/(main)/settings/index.tsx', + fileName: 'assets/settings-D8p.js', + moduleIds: ['/repo/src/routes/(main)/settings/index.tsx'], + }), + } satisfies TestOutputBundle; + const transformIndexHtml = plugin.transformIndexHtml as { + handler: (html: string, ctx: { bundle: TestOutputBundle }) => string; + }; + + configResolved({ base: '/_spa/', root: '/repo' }); + const result = transformIndexHtml.handler('', { + bundle, + }); + + expect(result).toContain('/_spa/assets/agent-CJm8x.js'); + expect(result).toContain('/_spa/assets/settings-D8p.js'); + expect(result).not.toContain('js-warmup-manifest.json'); + }); + + it('keeps tiny route dependencies out of initial html while preserving idle warmup coverage', () => { + const plugin = routeChunkPreload(); + const configResolved = plugin.configResolved as (config: { base: string; root: string }) => void; + const bundle = { + 'assets/agent-CJm8x.js': createChunk({ + code: 'x'.repeat(2048), + dynamicImports: ['assets/HeaderSlot-D8p.js'], + facadeModuleId: '/repo/src/routes/(main)/agent/index.tsx', + fileName: 'assets/agent-CJm8x.js', + moduleIds: ['/repo/src/routes/(main)/agent/index.tsx'], + }), + 'assets/HeaderSlot-D8p.js': createChunk({ + code: 'x'.repeat(128), + fileName: 'assets/HeaderSlot-D8p.js', + moduleIds: ['/repo/src/routes/(main)/agent/HeaderSlot.tsx'], + }), + } satisfies TestOutputBundle; + const transformIndexHtml = plugin.transformIndexHtml as { + handler: (html: string, ctx: { bundle: TestOutputBundle }) => string; + }; + + configResolved({ base: '/_spa/', root: '/repo' }); + const result = transformIndexHtml.handler('', { + bundle, + }); + + expect(result).toContain(' { + const plugin = routeChunkPreload({ + groups: [], + idleGroups: [ + { + id: 'custom-settings', + includeDynamicImports: true, + modules: ['src/routes/(main)/settings'], + patterns: ['^/settings(/|$)'], + }, + ], + }); + const configResolved = plugin.configResolved as (config: { base: string; root: string }) => void; + const bundle = { + 'assets/settings-CJm8x.js': createChunk({ + code: 'x'.repeat(2048), + dynamicImports: ['assets/tiny-D8p.js'], + facadeModuleId: '/repo/src/routes/(main)/settings/index.tsx', + fileName: 'assets/settings-CJm8x.js', + moduleIds: ['/repo/src/routes/(main)/settings/index.tsx'], + }), + 'assets/tiny-D8p.js': createChunk({ + code: 'x'.repeat(128), + fileName: 'assets/tiny-D8p.js', + moduleIds: ['/repo/src/routes/(main)/settings/Tiny.tsx'], + }), + } satisfies TestOutputBundle; + const transformIndexHtml = plugin.transformIndexHtml as { + handler: (html: string, ctx: { bundle: TestOutputBundle }) => string; + }; + + configResolved({ base: '/_spa/', root: '/repo' }); + const result = transformIndexHtml.handler('', { + bundle, + }); + + expect(result).toContain('"idleRouteFetch":[]'); + expect(result).toContain('"idleRoutePreload":["/_spa/assets/settings-CJm8x.js"'); + expect(result).not.toContain('/_spa/assets/tiny-D8p.js'); + }); + + it('can omit the all-JS warmup manifest while keeping idle route warmup', () => { + const result = __testing.injectIdleWarmupScriptIntoHtml( + '', + { idleRouteFetch: [], idleRoutePreload: ['assets/settings-D8p.js'] }, + '/_spa/', + 'dpl_test', + ); + + expect(result).toContain('/_spa/assets/settings-D8p.js?dpl=dpl_test'); + expect(result).not.toContain('js-warmup-manifest.json'); + }); + + it('can warm tiny route chunks through fetch without modulepreload links', () => { + const result = __testing.injectIdleWarmupScriptIntoHtml( + '', + { idleRouteFetch: ['assets/tiny-D8p.js'], idleRoutePreload: [] }, + '/_spa/', + 'dpl_test', + ); + + expect(result).toContain('"idleRouteFetch":["/_spa/assets/tiny-D8p.js?dpl=dpl_test"]'); + expect(result).toContain('"idleRoutePreload":[]'); + expect(result).toContain('warmQueue(m.idleRouteFetch||[])'); + }); +}); diff --git a/plugins/vite/routeChunkPreload.ts b/plugins/vite/routeChunkPreload.ts new file mode 100644 index 0000000000..2da537266a --- /dev/null +++ b/plugins/vite/routeChunkPreload.ts @@ -0,0 +1,526 @@ +import type { Plugin, ResolvedConfig } from 'vite'; + +interface RouteChunkPreloadRoute { + includeDynamicImports?: boolean; + id: string; + includeStaticImports?: boolean; + modules: string[]; + patterns: string[]; +} + +interface RuntimeRoutePreloadEntry { + id: string; + patterns: string[]; + preload: string[]; +} + +interface IdleWarmupManifest { + allJsManifestFileName?: string; + idleRouteFetch: string[]; + idleRoutePreload: string[]; +} + +interface OutputChunkLike { + code: string; + dynamicImports: string[]; + facadeModuleId: null | string; + fileName: string; + imports: string[]; + moduleIds: string[]; + type: 'chunk'; +} + +type OutputBundleLike = Record; + +const minInitialRoutePreloadSize = 2048; + +const criticalRouteSmallChunkFileNamePatterns = [ + /^EmptyNavItem-/, + /^HeaderSlot-/, + /^Item-/, + /^MainChatInput-/, + /^Notification-/, + /^PortalPanel-/, + /^RenameModal-/, + /^TokenTag-/, + /^agent-/, + /^router-/, + /^useAgentContext-/, + /^useAppOrigin-/, + /^useFetchChatTopics-/, + /^useFetchThreads-/, + /^useQueryParam-/, + /^useTokenCount-/, + /^useTopicPopupsRegistry-/, + /^withSuspense-/, +]; + +const isCriticalRouteSmallChunkFileName = (fileName: string) => { + const basename = normalizePath(fileName).split('/').at(-1) ?? fileName; + + return criticalRouteSmallChunkFileNamePatterns.some((pattern) => pattern.test(basename)); +}; + +const defaultRoutePreloadGroups = [ + { + id: 'desktop-chat-launch', + includeDynamicImports: true, + includeStaticImports: true, + modules: [ + 'src/routes/(main)/_layout', + 'src/routes/(main)/agent/_layout', + 'src/routes/(main)/agent/(chat)/_layout', + 'src/routes/(main)/agent', + ], + patterns: ['^/agent(/|$)'], + }, +] as const satisfies RouteChunkPreloadRoute[]; + +const defaultIdleRoutePreloadGroups = [ + { + id: 'desktop-agent-profile', + includeDynamicImports: true, + includeStaticImports: true, + modules: ['src/routes/(main)/agent/profile'], + patterns: ['^/agent/[^/]+/profile(/|$)'], + }, + { + id: 'desktop-agent-channel', + includeDynamicImports: true, + includeStaticImports: true, + modules: ['src/routes/(main)/agent/channel'], + patterns: ['^/agent/[^/]+/channel(/|$)'], + }, + { + id: 'desktop-agent-page', + includeDynamicImports: true, + includeStaticImports: true, + modules: [ + 'src/routes/(main)/agent/page', + 'src/routes/(main)/agent/[topicId]/page', + 'src/routes/(main)/agent/[topicId]/page/[docId]', + ], + patterns: ['^/agent/[^/]+(?:/[^/]+)?/page(/|$)'], + }, + { + id: 'desktop-community', + includeDynamicImports: true, + includeStaticImports: true, + modules: [ + 'src/routes/(main)/community/_layout', + 'src/routes/(main)/community/(list)/_layout', + 'src/routes/(main)/community/(detail)/_layout', + 'src/routes/(main)/community/(list)/(home)', + ], + patterns: ['^/community(/|$)'], + }, + { + id: 'desktop-resource', + includeDynamicImports: true, + includeStaticImports: true, + modules: [ + 'src/routes/(main)/resource/_layout', + 'src/routes/(main)/resource/(home)/_layout', + 'src/routes/(main)/resource/(home)', + 'src/routes/(main)/resource/library/_layout', + 'src/routes/(main)/resource/library', + 'src/routes/(main)/resource/library/[slug]', + ], + patterns: ['^/resource(/|$)'], + }, + { + id: 'desktop-settings', + includeDynamicImports: true, + includeStaticImports: true, + modules: ['src/routes/(main)/settings/_layout', 'src/routes/(main)/settings'], + patterns: ['^/settings(/|$)'], + }, + { + id: 'desktop-settings-provider', + includeDynamicImports: true, + includeStaticImports: true, + modules: ['src/routes/(main)/settings/provider'], + patterns: ['^/settings/provider(/|$)'], + }, + { + id: 'desktop-tasks', + includeDynamicImports: true, + includeStaticImports: true, + modules: [ + 'src/routes/(main)/(task-workspace)/_layout', + 'src/routes/(main)/tasks', + 'src/routes/(main)/task/[taskId]', + 'src/routes/(main)/agent/task/[taskId]', + ], + patterns: ['^/(tasks|task|agent/[^/]+/task)(/|$)'], + }, + { + id: 'desktop-page', + includeDynamicImports: true, + includeStaticImports: true, + modules: ['src/routes/(main)/page/_layout', 'src/routes/(main)/page', 'src/routes/(main)/page/[id]'], + patterns: ['^/page(/|$)'], + }, +] as const satisfies RouteChunkPreloadRoute[]; + +const allJsWarmupManifestFileName = 'assets/js-warmup-manifest.json'; + +const normalizePath = (value: string) => value.split('?')[0].replaceAll('\\', '/'); + +const isI18nChunkFileName = (fileName: string) => { + const normalized = normalizePath(fileName); + const basename = normalized.split('/').at(-1) ?? normalized; + + return normalized.startsWith('i18n/') || basename.startsWith('i18n-'); +}; + +const syntaxHighlightModulePatterns = [ + '/node_modules/@shikijs/', + '/node_modules/shiki/', + '/node_modules/oniguruma-to-es/', + '/node_modules/vscode-oniguruma/', + '/node_modules/vscode-textmate/', +]; + +const deferredRendererModulePatterns = [ + ...syntaxHighlightModulePatterns, + '/node_modules/@mermaid-js/', + '/node_modules/cytoscape/', + '/node_modules/dagre/', + '/node_modules/graphlib/', + '/node_modules/mermaid/', + '/node_modules/roughjs/', +]; + +const deferredRendererFileNamePatterns = [ + /(^|\/)(?:github-dark|catppuccin|pierre-dark|pierre-light)-[^/]+\.js$/i, + /(^|\/)(?:javascript|typescript|tsx|jsx|wasm)-[^/]+\.js$/i, + /(^|\/)mermaid(?:\.|-)[^/]+\.js$/i, + /(^|\/)(?:cytoscape|dagre|graphlib|rough)(?:\.|-)[^/]+\.js$/i, +]; + +function isDeferredRendererChunk(chunk: OutputChunkLike) { + if (deferredRendererFileNamePatterns.some((pattern) => pattern.test(chunk.fileName))) return true; + + const moduleIds = [chunk.facadeModuleId, ...chunk.moduleIds].filter(Boolean); + + return moduleIds.some((id) => { + const normalized = normalizePath(id!); + + return deferredRendererModulePatterns.some((pattern) => normalized.includes(pattern)); + }); +} + +function isPrewarmExcludedChunk(chunk: OutputChunkLike) { + return isI18nChunkFileName(chunk.fileName) || isDeferredRendererChunk(chunk); +} + +const stripModuleSuffix = (value: string) => + value + .replace(/\.(mjs|js|jsx|ts|tsx)$/, '') + .replace(/\.(desktop|mobile|vite|web)$/, '') + .replace(/\/index$/, ''); + +function normalizeComparableModuleId(id: string, root = '') { + let normalized = normalizePath(id); + const normalizedRoot = root ? normalizePath(root).replace(/\/$/, '') : ''; + + if (normalizedRoot && normalized.startsWith(`${normalizedRoot}/`)) { + normalized = normalized.slice(normalizedRoot.length + 1); + } + + if (normalized.startsWith('lobehub/src/')) { + normalized = normalized.slice('lobehub/'.length); + } + + return stripModuleSuffix(normalized); +} + +function isOutputChunk(item: OutputBundleLike[string]): item is OutputChunkLike { + return item.type === 'chunk'; +} + +function chunkContainsModule(chunk: OutputChunkLike, moduleId: string, root: string) { + const expected = normalizeComparableModuleId(moduleId, root); + const chunkModuleIds = [chunk.facadeModuleId, ...chunk.moduleIds].filter(Boolean); + + return chunkModuleIds.some((id) => normalizeComparableModuleId(id!, root) === expected); +} + +function collectChunkDependencies( + chunk: OutputChunkLike, + chunksByFileName: Map, + collected: Set, + options: { includeDynamicImports?: boolean; includeStaticImports?: boolean }, +) { + if (collected.has(chunk.fileName)) return; + if (isPrewarmExcludedChunk(chunk)) return; + + collected.add(chunk.fileName); + + const imports = [ + ...(options.includeStaticImports ? chunk.imports : []), + ...(options.includeDynamicImports ? chunk.dynamicImports : []), + ]; + + for (const importedFileName of imports) { + const importedChunk = chunksByFileName.get(importedFileName); + if (!importedChunk) continue; + collectChunkDependencies(importedChunk, chunksByFileName, collected, options); + } +} + +function createRoutePreloadManifest( + bundle: OutputBundleLike, + root: string, + groups: readonly RouteChunkPreloadRoute[] = defaultRoutePreloadGroups, +): RuntimeRoutePreloadEntry[] { + const chunks = Object.values(bundle).filter(isOutputChunk); + const chunksByFileName = new Map(chunks.map((chunk) => [chunk.fileName, chunk])); + + return groups + .map((route) => { + const preload = new Set(); + + for (const moduleId of route.modules) { + const matchingChunks = chunks.filter((chunk) => chunkContainsModule(chunk, moduleId, root)); + + for (const chunk of matchingChunks) { + if (route.includeStaticImports || route.includeDynamicImports) { + collectChunkDependencies(chunk, chunksByFileName, preload, { + includeDynamicImports: route.includeDynamicImports, + includeStaticImports: route.includeStaticImports, + }); + } else { + preload.add(chunk.fileName); + } + } + } + + return { + id: route.id, + patterns: [...route.patterns], + preload: [...preload].filter((fileName) => { + const chunk = chunksByFileName.get(fileName); + + return fileName.endsWith('.js') && (!chunk || !isPrewarmExcludedChunk(chunk)); + }), + }; + }) + .filter((entry) => entry.preload.length > 0); +} + +function appendDeploymentQuery(href: string, deploymentId = process.env.VERCEL_DEPLOYMENT_ID) { + if (!deploymentId || href.includes('dpl=')) return href; + + return `${href}${href.includes('?') ? '&' : '?'}dpl=${deploymentId}`; +} + +function createAssetHref(fileName: string, base: string, deploymentId?: string) { + if (base === '' || base === './') return appendDeploymentQuery(fileName, deploymentId); + + const normalizedBase = base.endsWith('/') ? base : `${base}/`; + return appendDeploymentQuery(`${normalizedBase}${fileName}`, deploymentId); +} + +function createAllJsWarmupManifest(bundle: OutputBundleLike) { + return Object.values(bundle) + .filter(isOutputChunk) + .filter((chunk) => chunk.fileName.endsWith('.js') && !isPrewarmExcludedChunk(chunk)) + .map((chunk) => chunk.fileName) + .sort(); +} + +function collectExistingHtmlAssets(html: string, base: string) { + const existing = new Set(); + const sourcePattern = /<(?:link|script)\b[^>]+(?:href|src)="([^"]+)"/g; + + for (const match of html.matchAll(sourcePattern)) { + existing.add(normalizeHtmlAssetHref(match[1], base)); + } + + return existing; +} + +function normalizeHtmlAssetHref(href: string, base: string) { + const cleanHref = href.split('?')[0]; + const basePrefix = base === '' || base === './' ? '' : base.endsWith('/') ? base : `${base}/`; + + return basePrefix && cleanHref.startsWith(basePrefix) ? cleanHref.slice(basePrefix.length) : cleanHref.replace(/^\//, ''); +} + +function removeSmallModulepreloadsFromHtml( + html: string, + base: string, + shouldKeepFile: (fileName: string) => boolean, +) { + return html.replace(/^[ \t]*]*href="([^"]+)"[^>]*>\n?/gm, (match, href: string) => { + const fileName = normalizeHtmlAssetHref(href, base); + + return shouldKeepFile(fileName) ? match : ''; + }); +} + +function injectRouteModulepreloadsIntoHtml( + html: string, + manifest: RuntimeRoutePreloadEntry[], + base: string, + deploymentId?: string, + shouldInjectFile: (fileName: string) => boolean = () => true, +) { + const existing = collectExistingHtmlAssets(html, base); + const routeFiles = new Set(manifest.flatMap((entry) => entry.preload)); + const links = [...routeFiles] + .filter(shouldInjectFile) + .filter((fileName) => !existing.has(fileName)) + .map((fileName) => ` `); + + if (links.length === 0) return html; + + const injection = links.join('\n'); + const lastModulepreloadMatch = [...html.matchAll(/^[ \t]*]*>$/gm)].at(-1); + + if (lastModulepreloadMatch?.index !== undefined) { + const insertAt = lastModulepreloadMatch.index + lastModulepreloadMatch[0].length; + return `${html.slice(0, insertAt)}\n${injection}${html.slice(insertAt)}`; + } + + return html.replace('', `${injection}\n `); +} + +function createIdleWarmupScript(manifest: IdleWarmupManifest, base: string, deploymentId?: string) { + const payload = { + allJsManifest: manifest.allJsManifestFileName + ? createAssetHref(manifest.allJsManifestFileName, base, deploymentId) + : undefined, + base, + idleRouteFetch: manifest.idleRouteFetch.map((fileName) => createAssetHref(fileName, base, deploymentId)), + idleRoutePreload: manifest.idleRoutePreload.map((fileName) => createAssetHref(fileName, base, deploymentId)), + }; + + return [ + ' ', + ].join('\n'); +} + +function injectIdleWarmupScriptIntoHtml(html: string, manifest: IdleWarmupManifest, base: string, deploymentId?: string) { + if (manifest.idleRoutePreload.length === 0 && manifest.idleRouteFetch.length === 0 && !manifest.allJsManifestFileName) + return html; + + return html.replace('', `${createIdleWarmupScript(manifest, base, deploymentId)}\n `); +} + +interface RouteChunkPreloadOptions { + allJsWarmup?: boolean; + groups?: readonly RouteChunkPreloadRoute[]; + idleGroups?: readonly RouteChunkPreloadRoute[]; +} + +export function routeChunkPreload(options: RouteChunkPreloadOptions = {}): Plugin { + let config: ResolvedConfig | undefined; + const groups = options.groups ?? defaultRoutePreloadGroups; + const idleGroups = options.idleGroups ?? defaultIdleRoutePreloadGroups; + const allJsWarmup = options.allJsWarmup ?? false; + + return { + name: 'lobe-route-chunk-preload', + configResolved(resolvedConfig) { + config = resolvedConfig; + }, + generateBundle(_, bundle) { + if (!allJsWarmup) return; + + this.emitFile({ + fileName: allJsWarmupManifestFileName, + source: JSON.stringify(createAllJsWarmupManifest(bundle as OutputBundleLike)), + type: 'asset', + }); + }, + transformIndexHtml: { + order: 'post', + handler(html, ctx) { + if (!config || !ctx.bundle) return html; + + const outputBundle = ctx.bundle as OutputBundleLike; + const manifest = createRoutePreloadManifest(outputBundle, config.root, groups); + const idleManifest = createRoutePreloadManifest(outputBundle, config.root, idleGroups); + const deploymentId = process.env.VERCEL_DEPLOYMENT_ID; + const chunkSizeByFileName = new Map( + Object.values(outputBundle) + .filter(isOutputChunk) + .map((chunk) => [chunk.fileName, Buffer.byteLength(chunk.code)]), + ); + const htmlWithoutSmallPreloads = removeSmallModulepreloadsFromHtml( + html, + config.base, + (fileName) => (chunkSizeByFileName.get(fileName) ?? minInitialRoutePreloadSize) >= minInitialRoutePreloadSize, + ); + const htmlWithInitialPreloads = injectRouteModulepreloadsIntoHtml( + htmlWithoutSmallPreloads, + manifest, + config.base, + deploymentId, + (fileName) => (chunkSizeByFileName.get(fileName) ?? minInitialRoutePreloadSize) >= minInitialRoutePreloadSize, + ); + + return injectIdleWarmupScriptIntoHtml( + htmlWithInitialPreloads, + { + allJsManifestFileName: allJsWarmup ? allJsWarmupManifestFileName : undefined, + idleRouteFetch: [], + idleRoutePreload: [ + ...new Set([ + ...manifest + .flatMap((entry) => entry.preload) + .filter((fileName) => { + const size = chunkSizeByFileName.get(fileName) ?? minInitialRoutePreloadSize; + + return size >= minInitialRoutePreloadSize || isCriticalRouteSmallChunkFileName(fileName); + }), + ...idleManifest + .flatMap((entry) => entry.preload) + .filter( + (fileName) => + (chunkSizeByFileName.get(fileName) ?? minInitialRoutePreloadSize) >= minInitialRoutePreloadSize, + ), + ]), + ], + }, + config.base, + deploymentId, + ); + }, + }, + }; +} + +export const __testing = { + appendDeploymentQuery, + collectExistingHtmlAssets, + createAllJsWarmupManifest, + createAssetHref, + createIdleWarmupScript, + createRoutePreloadManifest, + defaultIdleRoutePreloadGroups, + defaultRoutePreloadGroups, + injectIdleWarmupScriptIntoHtml, + injectRouteModulepreloadsIntoHtml, + removeSmallModulepreloadsFromHtml, +}; diff --git a/plugins/vite/sharedRendererConfig.test.ts b/plugins/vite/sharedRendererConfig.test.ts new file mode 100644 index 0000000000..3a439456e1 --- /dev/null +++ b/plugins/vite/sharedRendererConfig.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { __testing, sharedModulePreload } from './sharedRendererConfig'; + +describe('sharedModulePreload', () => { + it('keeps vendor modulepreload dependencies while excluding i18n chunks', () => { + const resolveDependencies = sharedModulePreload.resolveDependencies!; + + expect( + resolveDependencies( + 'assets/index.js', + [ + 'assets/vendor-icons.js', + 'vendor/vendor-react.js', + 'i18n/i18n-default.js', + 'assets/i18n-en-US.js', + 'assets/page.js', + ], + { hostId: 'index.html', hostType: 'html' }, + ), + ).toEqual(['assets/vendor-icons.js', 'vendor/vendor-react.js', 'assets/page.js']); + }); +}); + +describe('sharedManualChunks', () => { + it('groups stable runtime packages into coarse vendor chunks', () => { + expect( + __testing.sharedManualChunks('/repo/node_modules/.pnpm/react@19/node_modules/react/index.js'), + ).toBe('vendor-react'); + expect( + __testing.sharedManualChunks( + '/repo/node_modules/.pnpm/react-dom@19/node_modules/react-dom/client.js', + ), + ).toBe('vendor-react'); + expect( + __testing.sharedManualChunks( + '/repo/node_modules/.pnpm/@emotion+react/node_modules/@emotion/react/dist/index.js', + ), + ).toBe('vendor-ui-runtime'); + expect( + __testing.sharedManualChunks( + '/repo/node_modules/.pnpm/motion@12/node_modules/motion/react/dist/index.js', + ), + ).toBe('vendor-ui-runtime'); + expect( + __testing.sharedManualChunks( + '/repo/node_modules/.pnpm/lucide-react/node_modules/lucide-react/dist/index.js', + ), + ).toBe('vendor-icons'); + expect( + __testing.sharedManualChunks( + '/repo/node_modules/.pnpm/zustand@5/node_modules/zustand/esm/index.mjs', + ), + ).toBe('vendor-data-runtime'); + expect( + __testing.sharedManualChunks('/repo/packages/model-runtime/src/providers/openai/index.ts'), + ).toBe('vendor-ai-runtime'); + expect( + __testing.sharedManualChunks( + '/repo/node_modules/.pnpm/openai@4/node_modules/openai/index.mjs', + ), + ).toBe('vendor-ai-runtime'); + }); +}); diff --git a/plugins/vite/sharedRendererConfig.ts b/plugins/vite/sharedRendererConfig.ts index 89723c52c6..faeb366ffc 100644 --- a/plugins/vite/sharedRendererConfig.ts +++ b/plugins/vite/sharedRendererConfig.ts @@ -1,10 +1,12 @@ import react from '@vitejs/plugin-react'; import { codeInspectorPlugin } from 'code-inspector-plugin'; +import type { ModulePreloadOptions } from 'vite'; import { viteEmotionSpeedy } from './emotionSpeedy'; import { viteMarkdownImport } from './markdownImport'; import { viteNodeModuleStub } from './nodeModuleStub'; import { vitePlatformResolve } from './platformResolve'; +import { routeChunkPreload } from './routeChunkPreload'; /** * Shared manual chunk naming — groups leaf-node modules to reduce chunk file count. @@ -57,6 +59,12 @@ const DAYJS_LOCALE: Record = { 'zh-tw': 'zh-TW', }; +const isNodePackage = (id: string, packageName: string) => { + const normalized = id.replaceAll('\\', '/'); + + return normalized.includes(`/node_modules/${packageName}/`); +}; + function sharedManualChunks(id: string): string | undefined { // i18n locale JSON/TS files const localeMatch = id.match(/\/locales\/([^/]+)\/([^/.]+)/); @@ -67,6 +75,9 @@ function sharedManualChunks(id: string): string | undefined { return `i18n-${locale}`; } + if (id.includes('/packages/model-runtime/') || isNodePackage(id, 'openai')) + return 'vendor-ai-runtime'; + // model-bank (monorepo package — split before node_modules guard) if (id.includes('model-bank')) return 'providerConfig'; @@ -86,17 +97,37 @@ function sharedManualChunks(id: string): string | undefined { if (locale) return `i18n-${locale}`; } + if ( + isNodePackage(id, 'react') || + isNodePackage(id, 'react-dom') || + isNodePackage(id, 'react-router') || + isNodePackage(id, 'react-router-dom') || + isNodePackage(id, 'scheduler') + ) { + return 'vendor-react'; + } + + if ( + id.includes('es-toolkit') || + id.includes('@emotion/') || + id.includes('/motion/') || + id.includes('framer-motion') + ) { + return 'vendor-ui-runtime'; + } + + if ( + isNodePackage(id, 'dayjs') || + isNodePackage(id, 'i18next') || + isNodePackage(id, 'react-i18next') || + isNodePackage(id, 'swr') || + isNodePackage(id, 'zustand') + ) { + return 'vendor-data-runtime'; + } + // Lucide icons if (id.includes('lucide-react')) return 'vendor-icons'; - - // es-toolkit - if (id.includes('es-toolkit')) return 'vendor-es-toolkit'; - - // emotion (CSS-in-JS runtime) - if (id.includes('@emotion/')) return 'vendor-emotion'; - - // motion (framer-motion) - if (id.includes('/motion/') || id.includes('framer-motion')) return 'vendor-motion'; } const sharedChunkFileNames = (chunkInfo: { name: string }) => { @@ -106,6 +137,17 @@ const sharedChunkFileNames = (chunkInfo: { name: string }) => { return 'assets/[name]-[hash].js'; }; +const isI18nChunkFileName = (fileName: string) => { + const normalized = fileName.split('?')[0].replaceAll('\\', '/'); + const basename = normalized.split('/').at(-1) ?? normalized; + + return normalized.startsWith('i18n/') || basename.startsWith('i18n-'); +}; + +export const sharedModulePreload = { + resolveDependencies: (_filename, deps) => deps.filter((dep) => !isI18nChunkFileName(dep)), +} satisfies ModulePreloadOptions; + export const sharedRollupOutput = { chunkFileNames: sharedChunkFileNames, manualChunks: sharedManualChunks, @@ -130,6 +172,7 @@ export const createSharedRolldownOutput = (options: SharedRolldownOutputOptions type Platform = 'web' | 'mobile' | 'desktop'; const isDev = process.env.NODE_ENV !== 'production'; +const enableRouteChunkPreload = process.env.LOBE_ROUTE_CHUNK_PRELOAD !== 'false'; interface SharedRendererOptions { platform: Platform; @@ -142,6 +185,7 @@ export function sharedRendererPlugins(options: SharedRendererOptions) { viteMarkdownImport(), viteNodeModuleStub(), vitePlatformResolve(options.platform), + enableRouteChunkPreload && routeChunkPreload(), isDev && { name: 'lobe-dev-strip-manifest', @@ -220,3 +264,7 @@ export const sharedOptimizeDeps = { 'motion/react', ], }; + +export const __testing = { + sharedManualChunks, +}; diff --git a/src/features/AgentTasks/AgentTaskDetail/TopicChatDrawer/index.tsx b/src/features/AgentTasks/AgentTaskDetail/TopicChatDrawer/index.tsx index a842fafba3..828f4ac96e 100644 --- a/src/features/AgentTasks/AgentTaskDetail/TopicChatDrawer/index.tsx +++ b/src/features/AgentTasks/AgentTaskDetail/TopicChatDrawer/index.tsx @@ -13,13 +13,13 @@ import { } from '@lobehub/ui'; import { cssVar } from 'antd-style'; import { Copy, MoreHorizontal, Share2 } from 'lucide-react'; -import dynamic from 'next/dynamic'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { ChatList, ConversationProvider, MessageItem } from '@/features/Conversation'; import { TaskCardScopeProvider } from '@/features/Conversation/Markdown/plugins/Task'; import { useShareModal } from '@/features/ShareModal'; +import { LazySharePopover as SharePopover } from '@/features/SharePopover/lazy'; import { useGatewayReconnect } from '@/hooks/useGatewayReconnect'; import { useOperationState } from '@/hooks/useOperationState'; import { useAgentStore } from '@/store/agent'; @@ -35,8 +35,6 @@ import { authSelectors } from '@/store/user/selectors'; import TopicStatusIcon from '../TopicStatusIcon'; import FeedbackInput from './FeedbackInput'; -const SharePopover = dynamic(() => import('@/features/SharePopover')); - interface TopicChatDrawerBodyProps { agentId: string; taskId: string; diff --git a/src/features/Conversation/Messages/Tool/preload.ts b/src/features/Conversation/Messages/Tool/preload.ts deleted file mode 100644 index a732e80822..0000000000 --- a/src/features/Conversation/Messages/Tool/preload.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Preload Tool Render components to avoid Suspense flash on first expand - * - * These components are dynamically imported in Tool/Tool/index.tsx. - * By preloading them when tool calls are detected, we can avoid - * the loading skeleton flash when user first expands the tool result. - */ - -let preloaded = false; - -export const preloadToolRenderComponents = () => { - if (preloaded) return; - preloaded = true; - - // Preload Detail and Debug components (dynamic imports in Tool/Tool/index.tsx) - import('../AssistantGroup/Tool/Detail'); - import('../AssistantGroup/Tool/Debug'); -}; diff --git a/src/features/SharePopover/lazy.ts b/src/features/SharePopover/lazy.ts new file mode 100644 index 0000000000..e994465c32 --- /dev/null +++ b/src/features/SharePopover/lazy.ts @@ -0,0 +1,3 @@ +import dynamic from '@/libs/next/dynamic'; + +export const LazySharePopover = dynamic(() => import('.')); diff --git a/src/initialize.ts b/src/initialize.ts index 18601b77b3..19f0c09653 100644 --- a/src/initialize.ts +++ b/src/initialize.ts @@ -4,7 +4,6 @@ import isYesterday from 'dayjs/plugin/isYesterday'; import relativeTime from 'dayjs/plugin/relativeTime'; import utc from 'dayjs/plugin/utc'; import { enableMapSet, enablePatches } from 'immer'; -import { scan } from 'react-scan'; import { isChunkLoadError, notifyChunkError } from '@/utils/chunkError'; @@ -35,5 +34,7 @@ if (typeof window !== 'undefined') { } if (__DEV__) { - scan({ enabled: true }); + void import('react-scan').then(({ scan }) => { + scan({ enabled: true }); + }); } diff --git a/src/routes/(main)/agent/features/Conversation/Header/ShareButton/index.tsx b/src/routes/(main)/agent/features/Conversation/Header/ShareButton/index.tsx index e4c94e885e..2f594855b7 100644 --- a/src/routes/(main)/agent/features/Conversation/Header/ShareButton/index.tsx +++ b/src/routes/(main)/agent/features/Conversation/Header/ShareButton/index.tsx @@ -2,19 +2,17 @@ import { ActionIcon } from '@lobehub/ui'; import { Share2 } from 'lucide-react'; -import dynamic from 'next/dynamic'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { withSuspense } from '@/components/withSuspense'; import { DESKTOP_HEADER_ICON_SMALL_SIZE, MOBILE_HEADER_ICON_SIZE } from '@/const/layoutTokens'; import { useShareModal } from '@/features/ShareModal'; +import { LazySharePopover as SharePopover } from '@/features/SharePopover/lazy'; import { useChatStore } from '@/store/chat'; import { useServerConfigStore } from '@/store/serverConfig'; import { serverConfigSelectors } from '@/store/serverConfig/selectors'; -const SharePopover = dynamic(() => import('@/features/SharePopover')); - interface ShareButtonProps { mobile?: boolean; open?: boolean; diff --git a/src/routes/(main)/group/features/Conversation/Header/ShareButton/index.tsx b/src/routes/(main)/group/features/Conversation/Header/ShareButton/index.tsx index 32ec79da6f..a1cf961db5 100644 --- a/src/routes/(main)/group/features/Conversation/Header/ShareButton/index.tsx +++ b/src/routes/(main)/group/features/Conversation/Header/ShareButton/index.tsx @@ -7,13 +7,11 @@ import { useTranslation } from 'react-i18next'; import { DESKTOP_HEADER_ICON_SIZE, MOBILE_HEADER_ICON_SIZE } from '@/const/layoutTokens'; import { useShareModal } from '@/features/ShareModal'; -import dynamic from '@/libs/next/dynamic'; +import { LazySharePopover as SharePopover } from '@/features/SharePopover/lazy'; import { useChatStore } from '@/store/chat'; import { useServerConfigStore } from '@/store/serverConfig'; import { serverConfigSelectors } from '@/store/serverConfig/selectors'; -const SharePopover = dynamic(() => import('@/features/SharePopover')); - interface ShareButtonProps { mobile?: boolean; open?: boolean; diff --git a/src/routes/(main)/home/_layout/Body/Agent/List/AgentItem/index.tsx b/src/routes/(main)/home/_layout/Body/Agent/List/AgentItem/index.tsx index 79e1f07867..711a4bc416 100644 --- a/src/routes/(main)/home/_layout/Body/Agent/List/AgentItem/index.tsx +++ b/src/routes/(main)/home/_layout/Body/Agent/List/AgentItem/index.tsx @@ -15,7 +15,6 @@ import { useChatStore } from '@/store/chat'; import { operationSelectors } from '@/store/chat/selectors'; import { useGlobalStore } from '@/store/global'; import { useHomeStore } from '@/store/home'; -import { prefetchRoute } from '@/utils/router'; import { useAgentModal } from '../../ModalProvider'; import Actions from '../Item/Actions'; @@ -122,8 +121,7 @@ const AgentItem = memo(({ item, style, className, onNavigate }) // Memoize event handlers const handleMouseEnter = useCallback(() => { prefetchAgent(id); - prefetchRoute(agentUrl); - }, [id, prefetchAgent, agentUrl]); + }, [id, prefetchAgent]); const handleDoubleClick = useCallback(() => { openAgentInNewWindow(id); diff --git a/src/routes/(main)/home/_layout/Body/Agent/List/InboxItem.tsx b/src/routes/(main)/home/_layout/Body/Agent/List/InboxItem.tsx index 390a7c6ee0..6fd9d6a159 100644 --- a/src/routes/(main)/home/_layout/Body/Agent/List/InboxItem.tsx +++ b/src/routes/(main)/home/_layout/Body/Agent/List/InboxItem.tsx @@ -14,7 +14,6 @@ import { useAgentStore } from '@/store/agent'; import { agentSelectors, builtinAgentSelectors } from '@/store/agent/selectors'; import { useChatStore } from '@/store/chat'; import { operationSelectors } from '@/store/chat/selectors'; -import { prefetchRoute } from '@/utils/router'; const styles = createStaticStyles(({ css, cssVar }) => ({ runningBadge: css` @@ -61,7 +60,6 @@ const InboxItem = memo(({ className, style }) => { const inboxUrl = SESSION_CHAT_URL(inboxAgentId, false); // Prefetch agent layout chunk and data eagerly since Lobe AI is almost always clicked - prefetchRoute(inboxUrl); prefetchAgent(inboxAgentId!); const avatarNode = ( diff --git a/src/routes/(main)/home/_layout/Body/index.test.tsx b/src/routes/(main)/home/_layout/Body/index.test.tsx index 5bc9280194..8d007247df 100644 --- a/src/routes/(main)/home/_layout/Body/index.test.tsx +++ b/src/routes/(main)/home/_layout/Body/index.test.tsx @@ -71,10 +71,6 @@ vi.mock('@/utils/navigation', () => ({ isModifierClick: () => false, })); -vi.mock('@/utils/router', () => ({ - prefetchRoute: vi.fn(), -})); - vi.mock('@/routes/(main)/home/features/Recents', () => ({ default: ({ itemKey }: { itemKey: string }) =>
, })); diff --git a/src/routes/(main)/home/_layout/Body/index.tsx b/src/routes/(main)/home/_layout/Body/index.tsx index 680a3173dc..2d7ba50198 100644 --- a/src/routes/(main)/home/_layout/Body/index.tsx +++ b/src/routes/(main)/home/_layout/Body/index.tsx @@ -16,7 +16,6 @@ import Recents from '@/routes/(main)/home/features/Recents'; import { useGlobalStore } from '@/store/global'; import { systemStatusSelectors } from '@/store/global/selectors'; import { isModifierClick } from '@/utils/navigation'; -import { prefetchRoute } from '@/utils/router'; import Agent from './Agent'; import { openCustomizeSidebarModal } from './CustomizeSidebarModal'; @@ -125,7 +124,6 @@ const Body = memo(() => { prefetchRoute(navItem.url!)} onClick={(e) => { if (isModifierClick(e)) return; e.preventDefault(); diff --git a/src/routes/(main)/home/_layout/Footer/index.tsx b/src/routes/(main)/home/_layout/Footer/index.tsx index 7c0731e906..d04049c2d0 100644 --- a/src/routes/(main)/home/_layout/Footer/index.tsx +++ b/src/routes/(main)/home/_layout/Footer/index.tsx @@ -36,7 +36,6 @@ import { systemStatusSelectors } from '@/store/global/selectors/systemStatus'; import { useServerConfigStore } from '@/store/serverConfig'; import { useUserStore } from '@/store/user'; import { userGeneralSettingsSelectors } from '@/store/user/slices/settings/selectors/general'; -import { prefetchRoute } from '@/utils/router'; import { resolveFooterPromotionState } from './promotionPipeline'; @@ -387,7 +386,7 @@ const Footer = memo(() => { {isDevMode && ( - prefetchRoute('/settings')}> + { prefetchRoute(item.url!)} onClick={(e) => { if (isModifierClick(e)) return; e.preventDefault(); diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 5d4a5276a3..21fe524b93 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -42,6 +42,9 @@ declare global { /** Vite define: running under Vitest */ const __TEST__: boolean; + /** Vite define: enable react-scan diagnostic runtime */ + const __REACT_SCAN__: boolean; + /** Vite define: current bundle is mobile variant */ const __MOBILE__: boolean; diff --git a/src/utils/router.tsx b/src/utils/router.tsx index 74808c1431..81cd9c73fc 100644 --- a/src/utils/router.tsx +++ b/src/utils/router.tsx @@ -173,29 +173,3 @@ export function createAppRouter(routes: RouteObject[], options?: CreateAppRouter export function redirectElement(to: string): ReactElement { return ; } - -/** - * Prefetch route layout chunks on hover to reduce navigation delay. - * Each import is only triggered once — subsequent calls are no-ops. - */ -const prefetchedRoutes = new Set(); - -const routePrefetchMap: Record Promise> = { - '/agent': () => import('@/routes/(main)/agent/_layout'), - '/community': () => import('@/routes/(main)/community/_layout'), - '/group': () => import('@/routes/(main)/group/_layout'), - '/page': () => import('@/routes/(main)/page/_layout'), - '/resource': () => import('@/routes/(main)/resource/_layout'), - '/settings': () => import('@/routes/(main)/settings/_layout'), -}; - -export function prefetchRoute(path: string): void { - // Match the first path segment, e.g. "/settings/provider" -> "/settings" - const key = '/' + path.replace(/^\//, '').split('/')[0]; - if (prefetchedRoutes.has(key)) return; - const loader = routePrefetchMap[key]; - if (loader) { - prefetchedRoutes.add(key); - loader(); - } -} diff --git a/vite.config.ts b/vite.config.ts index 8259f2bf15..119985596f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,6 +2,7 @@ import { spawn } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import { DevTools } from '@vitejs/devtools'; import type { PluginOption, ViteDevServer } from 'vite'; import { defineConfig, loadEnv } from 'vite'; import { VitePWA } from 'vite-plugin-pwa'; @@ -9,6 +10,7 @@ import { VitePWA } from 'vite-plugin-pwa'; import { viteEnvRestartKeys } from './plugins/vite/envRestartKeys'; import { createSharedRolldownOutput, + sharedModulePreload, sharedOptimizeDeps, sharedRendererDefine, sharedRendererPlugins, @@ -22,6 +24,7 @@ Object.assign(process.env, loadEnv(mode, process.cwd(), '')); const isDev = process.env.NODE_ENV !== 'production'; const platform = isMobile ? 'mobile' : 'web'; +const enableViteDevTools = process.env.LOBE_VITE_DEVTOOLS === 'true'; const resolveCommandExecutable = (cmd: string) => { const pathValue = process.env.PATH; @@ -100,9 +103,11 @@ const openExternalBrowser = async ( export default defineConfig({ base: isDev ? '/' : process.env.VITE_CDN_BASE || '/_spa/', build: { + modulePreload: sharedModulePreload, outDir: isMobile ? 'dist/mobile' : 'dist/desktop', reportCompressedSize: false, rolldownOptions: { + ...(enableViteDevTools && { devtools: {} }), input: path.resolve(__dirname, isMobile ? 'index.mobile.html' : 'index.html'), output: createSharedRolldownOutput({ strictExecutionOrder: true }), }, @@ -118,6 +123,11 @@ export default defineConfig({ plugins: [ vercelSkewProtection(), viteEnvRestartKeys(['APP_URL']), + enableViteDevTools && DevTools({ + build: { + withApp: true, + }, + }), ...sharedRendererPlugins({ platform }), isDev && {