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:
Serhii Dziupin
2026-08-07 09:01:31 +03:00
committed by GitHub
co-authored by Serhii Dziupin
parent da3d467f82
commit bc380e6e1b
21 changed files with 585 additions and 380 deletions
+130 -75
View File
@@ -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;
};