Skip to content

perf(workspace-plugin): reuse a single api-extractor compiler state in generate-api - #36522

Open
Martin Hochel (Hotell) wants to merge 2 commits into
microsoft:masterfrom
Hotell:perf/generate-api-shared-compiler-state
Open

perf(workspace-plugin): reuse a single api-extractor compiler state in generate-api#36522
Martin Hochel (Hotell) wants to merge 2 commits into
microsoft:masterfrom
Hotell:perf/generate-api-shared-compiler-state

Conversation

@Hotell

@Hotell Martin Hochel (Hotell) commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Previous Behavior

The generate-api executor invoked API Extractor once per api-extractor config without passing a compilerState. API Extractor therefore created a brand new ts.Program internally on every invocation.

With exportSubpaths enabled this scales linearly with the number of export subpaths. react-headless-components-preview/library has 50 named subpaths + 1 primary entry point, so a single generate-api run built 51 TypeScript programs over the same declaration graph — ~40s for that one target.

On top of that, API Extractor prints its version preamble on every invocation, so the console was flooded with 51 identical, useless lines that gave no indication of which entry point was being processed:

Analysis will use the bundled TypeScript version 5.7.3
Analysis will use the bundled TypeScript version 5.7.3
Analysis will use the bundled TypeScript version 5.7.3
... (×51)

New Behavior

1. One shared TypeScript program

All api-extractor configs are now prepared up front, a single CompilerState is created from the primary config with every subpath entry passed via additionalEntryPoints, and that state is reused for all Extractor.invoke calls.

This is viable because every config already compiles with the identical overrideTsconfig produced by getTsConfigForApiExtractor, so the extra programs were genuinely redundant work. Only public API Extractor API is used (CompilerState.create / IExtractorInvokeOptions.compilerState) — no internals, no fork.

Results — react-headless-components-preview (51 entry points)

Before After
generate-api executor duration 40.56 s 5.26 s (~7.7×)
ts.createProgram calls 51 1

A secondary win: Collector.analyze() calls program.getSemanticDiagnostics() on each invocation, and TypeScript caches those per file on a Program instance — so calls 2..51 are now near-free instead of re-checking a fresh program each time.

2. Readable console output

The repeated version preamble is deduplicated via a shared messageCallback, and each entry point now logs what it is actually generating:

[1/51] Generating API for dist/index.d.ts
Analysis will use the bundled TypeScript version 5.7.3
[2/51] Generating API for dist/accordion.d.ts
[3/51] Generating API for dist/avatar.d.ts
[4/51] Generating API for dist/avatar-group.d.ts
[5/51] Generating API for dist/badge.d.ts
...
[51/51] Generating API for dist/tooltip.d.ts

Packages with a single entry point stay as quiet as before (the label drops to verbose level).

This also makes CI failures far easier to diagnose — the failing entry point is now obvious instead of being buried in 51 identical lines:

[11/51] Generating API for dist/dialog.d.ts
Warning: You have changed the API signature for this project. Please copy the file
"temp/dialog.api.md" to "etc/dialog.api.md", or perform a local build (which does
this automatically). See the Git repo documentation for more info.
API Extractor completed with 0 errors and 1 warnings

 NX   Running target generate-api for project react-headless-components-preview failed

Implementation notes

  • apiExtractor() was split into prepareExtractorConfig() and invokeExtractor(); createCompilerState() and createConsoleMessageDeduper() were added.
  • Export subpath expansion now runs before the primary extraction (still after the tsc --emitDeclarationOnly step, which wildcard expansion depends on).
  • The shared compiler state is created even for single-config packages, keeping one code path. Behaviour is identical since no typescriptCompilerFolder is passed.
  • Invocation count is unchanged (still one Extractor.invoke per entry point) — only program construction is deduplicated.
  • The messageCallback only marks console-preamble and console-compiler-version-notice as handled after their first occurrence. Every other message returns early untouched, so default console output and the errorCount/warningCount tally are unaffected.

Verification

  • Output parity is byte-for-byte: all 51 etc/*.api.md and 104 dist/**/*.d.ts rollups are identical to a pre-change baseline snapshot; git status is clean.
  • Error reporting is unaffected: deliberately drifted etc/dialog.api.md (entry 11 of 51) and ran with CI=true (forcing local: false) — the ApiReportNotCopied warning is still printed and still fails the target with exit code 1 (output above). This is expected from the code path too: the callback runs before the tally in MessageRouter._handleMessage, and non-local builds fail on errorCount + warningCount > 0.
  • react-button:generate-api (no export subpaths) is unaffected; repo-wide *.api.md diff is empty.
  • Unit tests updated — new coverage asserts CompilerState.create is called exactly once, that additionalEntryPoints matches the resolved subpath entry points, that every invocation receives the same instance, and that the deduper suppresses only repeated preambles while leaving unrelated console messages (e.g. ApiReportCopied) alone. 13/13 pass.
  • workspace-plugin:lint passes.

No change file — @fluentui/workspace-plugin is private.

Related Issue(s)

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

📊 Bundle size report

✅ No changes found

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

Pull request demo site: URL

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants