diff --git a/esbuild.config.js b/esbuild.config.js index c7aafb463..e46ec882c 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -74,9 +74,12 @@ const external = [ esbuild .build({ - entryPoints: ['packages/cli/index.ts'], + entryPoints: { cli: 'packages/cli/index.ts' }, bundle: true, - outfile: 'dist/cli.js', + outdir: 'dist', + entryNames: '[name]', + chunkNames: 'chunks/[name]-[hash]', + splitting: true, platform: 'node', format: 'esm', target: 'node22', @@ -103,6 +106,11 @@ esbuild 'process.env.CLI_VERSION': JSON.stringify(pkg.version), // Make global available for compatibility global: 'globalThis', + // Redirect free __dirname/__filename references to the shim so that + // vendored libraries that emit their own `var __dirname` locals don't + // collide with our injected bindings when code-splitting is enabled. + __dirname: '__qwen_dirname', + __filename: '__qwen_filename', }, loader: { '.node': 'file' }, plugins: [wasmBinaryPlugin, wasmLoader({ mode: 'embedded' })], diff --git a/packages/cli/src/ui/utils/CodeColorizer.tsx b/packages/cli/src/ui/utils/CodeColorizer.tsx index da0d99132..dce845301 100644 --- a/packages/cli/src/ui/utils/CodeColorizer.tsx +++ b/packages/cli/src/ui/utils/CodeColorizer.tsx @@ -6,7 +6,6 @@ import React from 'react'; import { Text, Box } from 'ink'; -import { common, createLowlight } from 'lowlight'; import type { Root, Element, @@ -22,11 +21,17 @@ import { } from '../components/shared/MaxSizedBox.js'; import type { LoadedSettings } from '../../config/settings.js'; import { createDebugLogger } from '@qwen-code/qwen-code-core'; +import { getLowlightInstance, loadLowlight } from './lowlightLoader.js'; -// Configure theming and parsing utilities. -const lowlight = createLowlight(common); const debugLogger = createDebugLogger('CODE_COLORIZER'); +// Lowlight is heavy (~1.5 MB bundled, ~36–60 ms V8 parse). It's loaded lazily +// from `./lowlightLoader.js` via dynamic import so it lives in a separate +// esbuild chunk that's only parsed once a code block actually needs +// highlighting. Callers see plain text for the very first render and the +// highlighted version once React next re-renders the surrounding subtree +// (typically on the next user keystroke or message). + function renderHastNode( node: Root | Element | HastText | RootContent, theme: Theme, @@ -97,11 +102,21 @@ function highlightAndRenderLine( language: string | null, theme: Theme, ): React.ReactNode { + // Trigger the lazy load on first use; until it resolves, fall back to a + // plain-text rendering of the line. The next React render of the + // surrounding subtree will pick up the highlighted version. + const ll = getLowlightInstance(); + if (!ll) { + void loadLowlight().catch((err) => { + debugLogger.error('[CodeColorizer] failed to load lowlight:', err); + }); + return line; + } try { const getHighlightedLine = () => - !language || !lowlight.registered(language) - ? lowlight.highlightAuto(line) - : lowlight.highlight(language, line); + !language || !ll.registered(language) + ? ll.highlightAuto(line) + : ll.highlight(language, line); const renderedNode = renderHastNode(getHighlightedLine(), theme, undefined); diff --git a/packages/cli/src/ui/utils/lowlightLoader.ts b/packages/cli/src/ui/utils/lowlightLoader.ts new file mode 100644 index 000000000..1b81872a8 --- /dev/null +++ b/packages/cli/src/ui/utils/lowlightLoader.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Standalone loader for the lowlight syntax-highlight engine. + * + * Kept in its own module — with zero imports beyond `lowlight` itself — so + * that priming the cache from `test-setup.ts` does not transitively pull + * `themeManager`, settings, or `@qwen-code/qwen-code-core` into every test + * file's module graph. That cascade was observed to alter theme/config test + * outcomes (e.g. theme-manager auto-detection and QWEN_HOME env tests). + */ + +import type { Root } from 'hast'; + +export type Lowlight = { + registered(language: string): boolean; + highlight(language: string, value: string): Root; + highlightAuto(value: string): Root; +}; + +let lowlightInstance: Lowlight | null = null; +let lowlightLoad: Promise | null = null; + +export function getLowlightInstance(): Lowlight | null { + return lowlightInstance; +} + +/** + * Kicks off (or returns the in-flight) load of the lowlight chunk. Exported + * for two callers: + * 1. `CodeColorizer.tsx` — fires the load on first colorize call so the + * next React commit picks up the highlighted output. + * 2. `test-setup.ts` — awaits this once to keep snapshot tests + * deterministic without dragging more modules into the test graph. + */ +export function loadLowlight(): Promise { + if (lowlightInstance) return Promise.resolve(lowlightInstance); + if (lowlightLoad) return lowlightLoad; + lowlightLoad = import('lowlight') + .then((mod) => { + lowlightInstance = mod.createLowlight(mod.common) as Lowlight; + return lowlightInstance; + }) + .catch((err) => { + lowlightLoad = null; + throw err; + }); + return lowlightLoad; +} diff --git a/packages/cli/test-setup.ts b/packages/cli/test-setup.ts index c26e57fa5..1a9725a59 100644 --- a/packages/cli/test-setup.ts +++ b/packages/cli/test-setup.ts @@ -16,3 +16,13 @@ if (process.env['QWEN_DEBUG_LOG_FILE'] === undefined) { } import './src/test-utils/customMatchers.js'; + +// Lowlight is loaded asynchronously in production to keep it out of the +// startup-critical bundle chunk. Snapshot tests render synchronously via +// `lastFrame()` and would otherwise capture the plain-text fallback before +// the dynamic import resolves. Prime the cache once here so every test sees +// the fully-highlighted output. The loader is intentionally a tiny standalone +// module (no transitive imports of themeManager / settings / core) so this +// prime does not perturb any other test's module graph. +import { loadLowlight } from './src/ui/utils/lowlightLoader.js'; +await loadLowlight(); diff --git a/scripts/create-standalone-package.js b/scripts/create-standalone-package.js index 968d6da45..3ed860533 100644 --- a/scripts/create-standalone-package.js +++ b/scripts/create-standalone-package.js @@ -39,6 +39,7 @@ const TARGETS = new Map([ const DIST_REQUIRED_PATHS = ['cli.js', 'vendor', 'bundled/qc-helper/docs']; const DIST_ALLOWED_ENTRIES = new Set([ 'cli.js', + 'chunks', 'vendor', 'bundled', 'package.json', diff --git a/scripts/esbuild-shims.js b/scripts/esbuild-shims.js index 9b71a3d4b..fbdfa6776 100644 --- a/scripts/esbuild-shims.js +++ b/scripts/esbuild-shims.js @@ -5,25 +5,27 @@ */ /** - * Shims for esbuild ESM bundles to support require() calls - * This file is injected into the bundle via esbuild's inject option + * Shims for esbuild ESM bundles. + * + * With code-splitting enabled, the inject is applied per-chunk and the + * exported bindings cannot collide with `var __dirname` polyfills that + * vendored libraries (e.g. yargs) emit in their own ESM compat layers. + * To stay collision-free, this file exposes prefixed names; the build + * config uses esbuild `define` to rewrite free `__dirname` / `__filename` + * references in source to these prefixed identifiers, while leaving + * vendor-declared locals untouched. */ import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; -// Create require function for the current module and make it global const _require = createRequire(import.meta.url); -// Make require available globally for dynamic requires if (typeof globalThis.require === 'undefined') { globalThis.require = _require; } -// Export for esbuild injection export const require = _require; - -// Setup __filename and __dirname for compatibility -export const __filename = fileURLToPath(import.meta.url); -export const __dirname = dirname(__filename); +export const __qwen_filename = fileURLToPath(import.meta.url); +export const __qwen_dirname = dirname(__qwen_filename); diff --git a/scripts/prepare-package.js b/scripts/prepare-package.js index 28811c0fb..b9235a489 100644 --- a/scripts/prepare-package.js +++ b/scripts/prepare-package.js @@ -159,6 +159,7 @@ const distPackageJson = { }, files: [ 'cli.js', + 'chunks', 'vendor', '*.sb', 'README.md',