️ perf: warm route chunks after idle (#15109)

* ️ perf: warm route chunks after idle

* 🐛 fix: normalize platform route chunk ids

* ️ perf: refine route chunk preloading

* 🔧 chore: keep desktop renderer preload unchanged

* ️ perf: skip renderer chunks in route warmup

* ️ perf: preload agent route dynamic chunks

* ️ perf: align route preload deployment urls

* ️ perf: coalesce stable vendor chunks

* ️ perf: group shared data runtime chunks

* ️ perf: group model runtime chunks

* ️ perf: trim initial route preloads

* ️ perf: limit idle route micro preloads

* ️ perf: strip tiny html modulepreloads

* ️ perf: prune redundant route chunk imports

* ️ perf: enable rolldown devtools

* ️ perf: gate vite devtools output

* ️ perf: optimize react-scan integration and update global types

Signed-off-by: Innei <tukon479@gmail.com>

* ️ perf: support cloud route chunk preload

---------

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2026-05-23 01:00:53 +08:00
committed by GitHub
parent 8a6545f799
commit 8cd03c8013
21 changed files with 1136 additions and 82 deletions
+1 -1
View File
@@ -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"
},
+3 -2
View File
@@ -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"
+461
View File
@@ -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<string, TestOutputChunk | { type: 'asset' }>;
function createChunk(overrides: Partial<TestOutputChunk>): 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 = [
'<html>',
' <head>',
' <script type="module" crossorigin src="/_spa/assets/index-D8p.js?dpl=dpl_test"></script>',
' <link rel="modulepreload" crossorigin href="/_spa/assets/existing-B2.js?dpl=dpl_test">',
' </head>',
'</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('<link rel="modulepreload" crossorigin href="/_spa/assets/page-B9kLm.js?dpl=dpl_test">');
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 = [
'<html>',
' <head>',
' <link rel="modulepreload" crossorigin href="/_spa/assets/small-D8p.js?dpl=dpl_test">',
' <link rel="modulepreload" crossorigin href="/_spa/assets/large-D8p.js?dpl=dpl_test">',
' </head>',
'</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(
'<html><head><script type="module" crossorigin src="/_spa/assets/index-D8p.js"></script></head><body></body></html>',
{ 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('<html><head></head><body></body></html>', {
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('<html><head></head><body></body></html>', {
bundle,
});
expect(result).toContain('<link rel="modulepreload" crossorigin href="/_spa/assets/agent-CJm8x.js');
expect(result).not.toContain('<link rel="modulepreload" crossorigin href="/_spa/assets/HeaderSlot-D8p.js');
expect(result).toContain('"idleRouteFetch":[]');
expect(result).toContain('"idleRoutePreload":["/_spa/assets/agent-CJm8x.js","/_spa/assets/HeaderSlot-D8p.js"');
expect(result).toContain('"/_spa/assets/HeaderSlot-D8p.js');
});
it('does not warm tiny low-priority idle route chunks', () => {
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('<html><head></head><body></body></html>', {
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(
'<html><body></body></html>',
{ 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(
'<html><body></body></html>',
{ 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||[])');
});
});
+526
View File
@@ -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<string, OutputChunkLike | { type: string }>;
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<string, OutputChunkLike>,
collected: Set<string>,
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<string>();
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<string>();
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]*<link\s+rel="modulepreload"[^>]*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) => ` <link rel="modulepreload" crossorigin href="${createAssetHref(fileName, base, deploymentId)}">`);
if (links.length === 0) return html;
const injection = links.join('\n');
const lastModulepreloadMatch = [...html.matchAll(/^[ \t]*<link\s+rel="modulepreload"[^>]*>$/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('</head>', `${injection}\n </head>`);
}
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 [
' <script>',
' (()=>{',
` const m=${JSON.stringify(payload)};`,
' const c=navigator.connection||navigator.mozConnection||navigator.webkitConnection;',
' if(c&&(c.saveData||/(^|-)2g$/.test(c.effectiveType||"")))return;',
' const seen=new Set([...document.querySelectorAll("link[href],script[src]")].map((n)=>n.href||n.src));',
' const idle=(cb)=>"requestIdleCallback"in window?requestIdleCallback(cb,{timeout:3e3}):setTimeout(()=>cb({didTimeout:true,timeRemaining:()=>16}),1200);',
' const visible=(cb)=>document.hidden?document.addEventListener("visibilitychange",()=>!document.hidden&&cb(),{once:true}):cb();',
' const run=(items,fn,batch,next)=>{let i=0;const step=(d)=>visible(()=>{let n=0;while(i<items.length&&n<batch&&(d.didTimeout||d.timeRemaining()>6)){fn(items[i++]);n++;}if(i<items.length)idle(step);else next&&idle(next);});idle(step);};',
' const addModulepreload=(href)=>{if(seen.has(href))return;seen.add(href);const l=document.createElement("link");l.rel="modulepreload";l.crossOrigin="";l.href=href;document.head.append(l);};',
' const warm=(href)=>{if(seen.has(href))return Promise.resolve();seen.add(href);return fetch(href,{cache:"force-cache",credentials:"same-origin"}).catch(()=>{});};',
' const warmQueue=(items)=>{let i=0,a=0;const pump=()=>visible(()=>{while(a<2&&i<items.length){a++;warm(items[i++]).finally(()=>{a--;idle(pump);});}});idle(pump);};',
' const toHref=(f)=>new URL(f,m.base&&m.base!=="./"?location.origin+m.base:location.href).href;',
' const warmAll=()=>{if(!m.allJsManifest)return;fetch(m.allJsManifest,{cache:"force-cache",credentials:"same-origin"}).then((r)=>r.ok?r.json():[]).then((files)=>warmQueue(files.map(toHref))).catch(()=>{});};',
' const start=()=>setTimeout(()=>idle(()=>run(m.idleRoutePreload,addModulepreload,4,()=>{warmQueue(m.idleRouteFetch||[]);setTimeout(()=>idle(warmAll),1.2e4);})),2e3);',
' document.readyState==="complete"?start():window.addEventListener("load",start,{once:true});',
' })();',
' </script>',
].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('</body>', `${createIdleWarmupScript(manifest, base, deploymentId)}\n </body>`);
}
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,
};
+64
View File
@@ -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');
});
});
+57 -9
View File
@@ -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<string, string> = {
'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,
};
@@ -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;
@@ -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');
};
+3
View File
@@ -0,0 +1,3 @@
import dynamic from '@/libs/next/dynamic';
export const LazySharePopover = dynamic(() => import('.'));
+3 -2
View File
@@ -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 });
});
}
@@ -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;
@@ -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;
@@ -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<AgentItemProps>(({ item, style, className, onNavigate })
// Memoize event handlers
const handleMouseEnter = useCallback(() => {
prefetchAgent(id);
prefetchRoute(agentUrl);
}, [id, prefetchAgent, agentUrl]);
}, [id, prefetchAgent]);
const handleDoubleClick = useCallback(() => {
openAgentInNewWindow(id);
@@ -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<InboxItemProps>(({ 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 = (
@@ -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 }) => <div data-testid={`sidebar-item-${itemKey}`} />,
}));
@@ -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(() => {
<Link
key={key}
to={navItem.url!}
onMouseEnter={() => prefetchRoute(navItem.url!)}
onClick={(e) => {
if (isModifierClick(e)) return;
e.preventDefault();
@@ -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(() => {
<ActionIcon aria-label={t('userPanel.help')} icon={CircleHelp} size={16} />
</DropdownMenu>
{isDevMode && (
<Link to="/settings" onMouseEnter={() => prefetchRoute('/settings')}>
<Link to="/settings">
<ActionIcon
aria-label={t('userPanel.setting')}
icon={SettingsIcon}
@@ -10,7 +10,6 @@ import NavItem from '@/features/NavPanel/components/NavItem';
import { useActiveTabKey } from '@/hooks/useActiveTabKey';
import { useNavLayout } from '@/hooks/useNavLayout';
import { isModifierClick } from '@/utils/navigation';
import { prefetchRoute } from '@/utils/router';
/** Keys that are rendered in the header; all others are managed by Body via sidebarSectionOrder */
const HEADER_KEYS = new Set(['home', 'search']);
@@ -51,7 +50,6 @@ const Nav = memo(() => {
<Link
key={item.key}
to={item.url}
onMouseEnter={() => prefetchRoute(item.url!)}
onClick={(e) => {
if (isModifierClick(e)) return;
e.preventDefault();
+3
View File
@@ -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;
-26
View File
@@ -173,29 +173,3 @@ export function createAppRouter(routes: RouteObject[], options?: CreateAppRouter
export function redirectElement(to: string): ReactElement {
return <Navigate replace to={to} />;
}
/**
* 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<string>();
const routePrefetchMap: Record<string, () => Promise<unknown>> = {
'/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();
}
}
+10
View File
@@ -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 && {