mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
⚡️ 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:
@@ -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
@@ -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"
|
||||
|
||||
@@ -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||[])');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import dynamic from '@/libs/next/dynamic';
|
||||
|
||||
export const LazySharePopover = dynamic(() => import('.'));
|
||||
+3
-2
@@ -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();
|
||||
|
||||
Vendored
+3
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 && {
|
||||
|
||||
Reference in New Issue
Block a user