perf: cut cold-start download 58% and startup heap 22% via measured chunk-graph fixes (#2742)
* fix(ui): update session-switch-resync test to current handleEvent/setSessionTodos signatures
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
* perf(build): split Shiki grammars/themes, CodeMirror legacy modes, and @pierre/diffs into on-demand chunks
Merging @shikijs/langs into one manual vendor chunk made the first language
request download every grammar (7.4 MB raw / 1 MB gzip). Letting Rollup split
these packages per dynamically imported module downloads only the languages,
themes, and modes actually used — matching how the worker build already
behaves. @pierre/diffs is split the same way so its pure patch parser (used by
the eager tool renderer) no longer drags the Shiki-importing render stack into
the startup graph.
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
* perf(ui): restore lazy heavy views and stop eager settings-graph loading
- MainLayout: DiffView/FilesView/GitView/PlanView return to lazyWithChunkRecovery
(they were silently made static in 2031e3b4 while their Suspense wrappers
remained), keeping the CodeMirror and @pierre/diffs stacks out of startup.
- ContextPanel: same lazy treatment for its Diff/Files/Git/Plan/Walkthrough
tabs, with null Suspense fallbacks.
- CommandPalette imported getSettingsNavIcon from SettingsView, statically
pulling the entire settings surface (SkillsPage -> CodeMirrorEditor -> vim
mode, theme registry -> @pierre/diffs) into the eager graph; the helper now
lives in lib/settings/metadata.
- The windowed SettingsWindow mounts only after its first open: rendering the
lazy component closed made React fetch the SettingsView chunk graph at
startup.
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
* perf(ui): keep @pierre/diffs + Shiki out of the eager chat graph and defer diff worker warmup
- DiffWorkerProvider no longer statically imports @pierre/diffs/worker or the
theme registry, and no longer spawns 3 workers plus a main-thread shared
highlighter during mount. Pools are created on demand through a dynamic
module load, warmed via requestIdleCallback after startup settles, and
useWorkerPool notifies consumers when a pool becomes available.
- ToolPart's rich diff preview moves to lazily loaded ToolPartDiffPreview;
the plain-text patch (PlainDiffFallback) renders while the chunk loads,
mirroring the existing error fallback. Theme registration happens during
render inside the lazy module so PatchDiff never renders unregistered ids.
- ChatInput mounts its lazy ToolOutputDialog only after the first attachment
preview opens instead of fetching the dialog chunk on the draft screen.
- getMarkdownSyntaxVars moves to a pierre-free markdownSyntaxVars module so
eager code-rendering consumers stop importing the registration module.
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
* perf(web): load ghostty-web and Nerd Fonts on first terminal use
- ghostty-web (638 KB raw JS + WASM VT) is dynamically imported when a
terminal actually mounts; TerminalView stays eagerly importable for the
bottom dock.
- The ~2 MB of CDN Nerd Fonts are no longer preloaded and force-loaded on
every cold start. index.html exposes an idempotent
__openchamberEnsureNerdFonts hook; TerminalViewport requests it on mount
and waits up to 2s so a cached font is in place before the glyph atlas is
built, while a cold CDN fetch never blocks the terminal. Runtimes without
the hook (VS Code, mobile) resolve immediately, matching their existing
fallback-font behavior.
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
---------
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
co-authored by
Serhii Dziupin
parent
da3d467f82
commit
bc380e6e1b
@@ -1,12 +1,16 @@
|
||||
import React, { useMemo, useEffect } from 'react';
|
||||
import React, { useEffect, useSyncExternalStore } from 'react';
|
||||
import type { SupportedLanguages } from '@pierre/diffs';
|
||||
import { WorkerPoolManager } from '@pierre/diffs/worker';
|
||||
import type { WorkerPoolManager } from '@pierre/diffs/worker';
|
||||
|
||||
import { useOptionalThemeSystem } from './useThemeSystem';
|
||||
import { workerFactory } from '@/lib/diff/workerFactory';
|
||||
import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
|
||||
import { getDefaultTheme } from '@/lib/theme/themes';
|
||||
import type { Theme } from '@/types/theme';
|
||||
// NOTE: keep provider lightweight; avoid main-thread diff parsing here.
|
||||
// This module must not statically import `@pierre/diffs` runtime code:
|
||||
// `@pierre/diffs/worker` pulls the Shiki highlighter (core + oniguruma engine
|
||||
// + grammar registry) into the eager startup graph and `initialize()` spawns
|
||||
// workers plus a main-thread shared highlighter before any diff is visible.
|
||||
// Everything heavy loads on demand and is only warmed after startup idle.
|
||||
|
||||
// Preload common languages for faster initial diff rendering
|
||||
const PRELOAD_LANGS: SupportedLanguages[] = [
|
||||
@@ -38,68 +42,90 @@ const WORKER_POOL_CONFIG: Record<WorkerPoolStyle, { poolSize: number; totalASTLR
|
||||
},
|
||||
};
|
||||
|
||||
let unifiedWorkerPool: WorkerPoolManager | undefined;
|
||||
let splitWorkerPool: WorkerPoolManager | undefined;
|
||||
type PoolModules = {
|
||||
WorkerPoolManager: typeof WorkerPoolManager;
|
||||
workerFactory: () => Worker;
|
||||
ensurePierreThemeRegistered: (theme: Theme) => void;
|
||||
};
|
||||
|
||||
const createWorkerPool = (style: WorkerPoolStyle) => {
|
||||
const config = WORKER_POOL_CONFIG[style];
|
||||
const pool = new WorkerPoolManager(
|
||||
{
|
||||
workerFactory,
|
||||
poolSize: config.poolSize,
|
||||
totalASTLRUCacheSize: config.totalASTLRUCacheSize,
|
||||
},
|
||||
{
|
||||
theme: {
|
||||
light: 'pierre-light',
|
||||
dark: 'pierre-dark',
|
||||
let poolModulesPromise: Promise<PoolModules> | null = null;
|
||||
|
||||
const loadPoolModules = (): Promise<PoolModules> => {
|
||||
poolModulesPromise ??= Promise.all([
|
||||
import('@pierre/diffs/worker'),
|
||||
import('@/lib/diff/workerFactory'),
|
||||
import('@/lib/shiki/appThemeRegistry'),
|
||||
]).then(([workerModule, factoryModule, themeRegistryModule]) => ({
|
||||
WorkerPoolManager: workerModule.WorkerPoolManager,
|
||||
workerFactory: factoryModule.workerFactory,
|
||||
ensurePierreThemeRegistered: themeRegistryModule.ensurePierreThemeRegistered,
|
||||
}));
|
||||
return poolModulesPromise;
|
||||
};
|
||||
|
||||
const pools: Partial<Record<WorkerPoolStyle, WorkerPoolManager>> = {};
|
||||
const poolsRequested = new Set<WorkerPoolStyle>();
|
||||
const poolListeners = new Set<() => void>();
|
||||
|
||||
let currentRenderTheme: { light: string; dark: string } = {
|
||||
light: 'pierre-light',
|
||||
dark: 'pierre-dark',
|
||||
};
|
||||
|
||||
const notifyPoolListeners = () => {
|
||||
for (const listener of poolListeners) listener();
|
||||
};
|
||||
|
||||
const applyRenderOptions = (style: WorkerPoolStyle, pool: WorkerPoolManager) => {
|
||||
void pool.setRenderOptions({
|
||||
theme: currentRenderTheme,
|
||||
lineDiffType: WORKER_POOL_CONFIG[style].lineDiffType,
|
||||
});
|
||||
};
|
||||
|
||||
const ensurePool = (style: WorkerPoolStyle): void => {
|
||||
if (typeof window === 'undefined' || poolsRequested.has(style)) return;
|
||||
poolsRequested.add(style);
|
||||
void loadPoolModules().then((modules) => {
|
||||
if (pools[style]) return;
|
||||
const config = WORKER_POOL_CONFIG[style];
|
||||
const pool = new modules.WorkerPoolManager(
|
||||
{
|
||||
workerFactory: modules.workerFactory,
|
||||
poolSize: config.poolSize,
|
||||
totalASTLRUCacheSize: config.totalASTLRUCacheSize,
|
||||
},
|
||||
langs: PRELOAD_LANGS,
|
||||
lineDiffType: config.lineDiffType,
|
||||
preferredHighlighter: 'shiki-wasm',
|
||||
}
|
||||
);
|
||||
void pool.initialize();
|
||||
return pool;
|
||||
{
|
||||
theme: {
|
||||
light: 'pierre-light',
|
||||
dark: 'pierre-dark',
|
||||
},
|
||||
langs: PRELOAD_LANGS,
|
||||
lineDiffType: config.lineDiffType,
|
||||
preferredHighlighter: 'shiki-wasm',
|
||||
}
|
||||
);
|
||||
void pool.initialize();
|
||||
pools[style] = pool;
|
||||
applyRenderOptions(style, pool);
|
||||
notifyPoolListeners();
|
||||
});
|
||||
};
|
||||
|
||||
const getWorkerPool = (style: WorkerPoolStyle): WorkerPoolManager | undefined => {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (style === 'split') {
|
||||
splitWorkerPool ??= createWorkerPool('split');
|
||||
return splitWorkerPool;
|
||||
}
|
||||
|
||||
unifiedWorkerPool ??= createWorkerPool('unified');
|
||||
return unifiedWorkerPool;
|
||||
const subscribeToPools = (listener: () => void): (() => void) => {
|
||||
poolListeners.add(listener);
|
||||
return () => poolListeners.delete(listener);
|
||||
};
|
||||
|
||||
const WorkerPoolWarmup: React.FC<{
|
||||
children: React.ReactNode;
|
||||
renderTheme: { light: string; dark: string };
|
||||
}> = ({ children, renderTheme }) => {
|
||||
const unifiedPool = useWorkerPool('unified');
|
||||
const splitPool = useWorkerPool('split');
|
||||
|
||||
useEffect(() => {
|
||||
if (unifiedPool) {
|
||||
void unifiedPool.setRenderOptions({
|
||||
theme: renderTheme,
|
||||
lineDiffType: WORKER_POOL_CONFIG.unified.lineDiffType,
|
||||
});
|
||||
}
|
||||
if (splitPool) {
|
||||
void splitPool.setRenderOptions({
|
||||
theme: renderTheme,
|
||||
lineDiffType: WORKER_POOL_CONFIG.split.lineDiffType,
|
||||
});
|
||||
}
|
||||
}, [renderTheme, splitPool, unifiedPool]);
|
||||
|
||||
return <>{children}</>;
|
||||
const setRenderTheme = (renderTheme: { light: string; dark: string }) => {
|
||||
if (currentRenderTheme.light === renderTheme.light && currentRenderTheme.dark === renderTheme.dark) {
|
||||
return;
|
||||
}
|
||||
currentRenderTheme = renderTheme;
|
||||
for (const style of Object.keys(pools) as WorkerPoolStyle[]) {
|
||||
const pool = pools[style];
|
||||
if (pool) applyRenderOptions(style, pool);
|
||||
}
|
||||
};
|
||||
|
||||
export const DiffWorkerProvider: React.FC<DiffWorkerProviderProps> = ({ children }) => {
|
||||
@@ -118,25 +144,54 @@ export const DiffWorkerProvider: React.FC<DiffWorkerProviderProps> = ({ children
|
||||
themeSystem?.availableThemes.find((theme) => theme.metadata.id === darkThemeId) ??
|
||||
fallbackDark;
|
||||
|
||||
ensurePierreThemeRegistered(lightTheme);
|
||||
ensurePierreThemeRegistered(darkTheme);
|
||||
// Register the active app themes with @pierre/diffs and forward them to any
|
||||
// live pools. Registration goes through the deferred module load so the
|
||||
// theme registry (and its @pierre/diffs import) stays out of the eager
|
||||
// startup graph; each diff surface also registers the themes it renders
|
||||
// with, so ordering is preserved even before this resolves.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void loadPoolModules().then((modules) => {
|
||||
if (cancelled) return;
|
||||
modules.ensurePierreThemeRegistered(lightTheme);
|
||||
modules.ensurePierreThemeRegistered(darkTheme);
|
||||
setRenderTheme({ light: lightTheme.metadata.id, dark: darkTheme.metadata.id });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [darkTheme, lightTheme]);
|
||||
|
||||
const renderTheme = useMemo(
|
||||
() => ({
|
||||
light: lightTheme.metadata.id,
|
||||
dark: darkTheme.metadata.id,
|
||||
}),
|
||||
[darkTheme.metadata.id, lightTheme.metadata.id],
|
||||
);
|
||||
// Warm the worker pools once startup work has settled so the first diff a
|
||||
// user opens does not pay worker spawn + highlighter init. Idle-deferred:
|
||||
// warming competed with initial load (3 workers, shiki grammars, oniguruma
|
||||
// wasm) when it ran during mount.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const warm = () => {
|
||||
ensurePool('unified');
|
||||
ensurePool('split');
|
||||
};
|
||||
if (typeof window.requestIdleCallback === 'function') {
|
||||
const handle = window.requestIdleCallback(warm, { timeout: 5000 });
|
||||
return () => window.cancelIdleCallback(handle);
|
||||
}
|
||||
const timeout = window.setTimeout(warm, 2000);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<WorkerPoolWarmup renderTheme={renderTheme}>
|
||||
{children}
|
||||
</WorkerPoolWarmup>
|
||||
);
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const useWorkerPool = (style: WorkerPoolStyle = 'unified'): WorkerPoolManager | undefined => {
|
||||
return useMemo(() => getWorkerPool(style), [style]);
|
||||
const pool = useSyncExternalStore(
|
||||
subscribeToPools,
|
||||
() => pools[style],
|
||||
() => undefined,
|
||||
);
|
||||
useEffect(() => {
|
||||
ensurePool(style);
|
||||
}, [style]);
|
||||
return pool;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user