Skip to content

Lazily load compose LSP client features and Java scaffolding parsers - #595

Draft
Brandon Waterloo [MSFT] (bwateratmsft) wants to merge 1 commit into
mainfrom
bmw/lazy-compose-lsp-and-java-parsers
Draft

Lazily load compose LSP client features and Java scaffolding parsers#595
Brandon Waterloo [MSFT] (bwateratmsft) wants to merge 1 commit into
mainfrom
bmw/lazy-compose-lsp-and-java-parsers

Conversation

@bwateratmsft

Copy link
Copy Markdown
Collaborator

🤖

Summary

Defers two static imports that sat on the activation path but are only needed for specific, user-initiated work. Together they cut the statically-reachable ("eager") set by 27% and median bundle load time by ~10%.

This is a small, self-contained piece of the 2.4.0 code-loading regression work. It does not change the bundler.

The changes

1. Compose language-service client features (extension.ts)

AlternateYamlLanguageServiceClientFeature and DocumentSettingsClientFeature were statically imported but are used only inside activateComposeLanguageClient, which already awaits getLanguageClient().

That single import was the only runtime edge into the LSP stack — every other import in those files is already import type. The chain:

extension.ts
  └─ compose-language-service/vscode  (barrel)
      └─ DocumentSettingsClientFeature
          └─ DocumentSettingsClientCapabilities   ← `new RequestType(...)` at module scope
              └─ vscode-languageserver-protocol
                  └─ vscode-jsonrpc

vscode-languageserver-protocol's entry is a CJS barrel:

__exportStar(require("vscode-jsonrpc"), exports);
__exportStar(require("vscode-languageserver-types"), exports);
__exportStar(require("./messages"), exports);
__exportStar(require("./protocol"), exports);

__exportStar is a runtime for...in copy loop, so no bundler can determine which export came from which module — nothing can be pruned. Importing RequestType (a 9-line class in messages.js) therefore pulled in ~305 KB: the full protocol definitions, the LSP type system, and the JSON-RPC connection machinery. On every activation, including for users who only ever open a Dockerfile.

2. Java scaffolding parsers (JavaGatherInformationStep.ts)

fast-xml-parser and gradle-to-js were imported at module scope but used only inside prompt() — ~0.24 MB of parsing code loaded for everyone, to serve users scaffolding a Java project.

Results

bundle bytes eager set median require()
before 2,988,896 1.97 MB 162.6 ms
after 2,989,313 1.44 MB 146.4 ms

Measured with 11 interleaved runs per variant, comparing medians.

Bundle size is intentionally unchanged (+417 bytes, from esbuild's __esm({...}) lazy-initializer wrappers). esbuild has no code splitting in CJS output, so the code still lives in the entry file and is still read and pre-parsed by V8. What changes is that these module bodies no longer execute at load — which is why the win is ~10% rather than the 27% the eager-set number alone would suggest.

The second benefit is structural: this converts both subtrees to dynamic-only reachability, which is what makes them eligible to be emitted as separate on-demand chunks. In a chunking prototype, these two changes take the entry bundle from 1.10 MB to 1.02 MB.

Notes for reviewers

  • The destructured await import() form is safe for these three packages, verified by bundling and inspecting the resolved namespaces. @microsoft/compose-language-service/vscode is a workspace TS package (real ESM bindings); fast-xml-parser exposes discrete named exports (XMLBuilder, XMLParser, XMLValidator, no default); gradle-to-js gets both default and named parseText/parseFile. None needs the getDefaultExport() helper from lazyPackages.ts — that is required only for CJS packages that assign a single object to module.exports (e.g. handlebars), which esbuild cannot statically enumerate.
  • Both dynamic imports sit behind existing await boundaries, so no call sites changed shape and no new async was introduced.
  • tsc --noEmit and eslint --max-warnings 0 both pass.

Testing

  • Type-check and lint clean
  • Bundle rebuilt; eager-set reduction confirmed against the esbuild metafile (vscode-jsonrpc is no longer statically reachable)
  • Emitted bundle inspected to confirm the deferral survives minification (await Promise.resolve().then(() => (init_vscode(), vscode_exports)))
  • fast-xml-parser and gradle-to-js round-tripped through the built bundle against real POM and Gradle content
  • Manual smoke test: open a compose file (language server starts, settings/YAML features work) and scaffold a Java project from both a pom.xml and a build.gradle

Related

Both of these were statically imported by modules on the activation path,
so their entire dependency subtrees were executed on every activation even
though they are only needed for specific, user-initiated work.

- `extension.ts` imported `AlternateYamlLanguageServiceClientFeature` and
  `DocumentSettingsClientFeature`, used only inside
  `activateComposeLanguageClient`. That import was the sole runtime edge into
  `vscode-languageserver-protocol`, whose CJS barrel re-exports
  `vscode-jsonrpc`, `vscode-languageserver-types`, `messages` and `protocol`
  via `__exportStar`. Because that is a runtime `for...in` copy loop, none of
  it can be tree-shaken, so ~0.30 MB of protocol definitions, LSP types and
  JSON-RPC connection machinery was loaded for every activation - including
  for users who only ever open a Dockerfile.

- `JavaGatherInformationStep.ts` imported `fast-xml-parser` and `gradle-to-js`
  at module scope, though both are used only inside `prompt()`. That put
  ~0.24 MB of XML/Gradle parsing on every activation to serve users
  scaffolding a Java project.

Deferring both drops the statically-reachable (eager) set from 1.97 MB to
1.44 MB, and median `require()` time of the extension bundle from ~163 ms to
~146 ms.

Note that this does not change bundle size - esbuild has no code splitting in
CJS output, so the code still lives in the entry file and is still read and
pre-parsed. What changes is that these module bodies no longer execute at load.
It also converts these subtrees to dynamic-only reachability, which is a
prerequisite for splitting them into separate on-demand chunks later.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e7a415ce-0c51-4b23-b395-191daf222ea7
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant