You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Yes, this behavior used to work in the previous version
The previous version in which this bug was not present was
21.2.9
Description
When two component definitions in the same source file declare byte-identical inline styles, the @angular/build:unit-test builder (vitest browser runner, watch:false) runs all tests, prints the passing summary, and then never exits. In CI the job is killed at its timeout.
The hang is caused by a leaked esbuild build context. process._getActiveHandles() at the hang shows a single non-stdio handle: a ChildProcess for …/@esbuild/…/esbuild --service=… --ping. esbuild's service child is a singleton kept alive by any live build context, so one orphaned context keeps the Node event loop alive forever. Killing only that esbuild child makes the runner exit immediately with code 0.
Root cause
Two compounding check-then-act races on the concurrent component-stylesheet bundling path:
Cache.getOrCreate (tools/esbuild/cache.ts) is not concurrency-safe — it await store.get(), and only on undefined does it await creator() then store.set(). There is no in-flight promise memoization.
Inline component styles are cached in a MemoryCache keyed on [language, sha256(content), filename] (tools/esbuild/angular/component-stylesheets.ts → bundleInline, #inlineContexts). Two components in the same file with identical CSS produce the same key.
The compiler plugin bundles component stylesheets concurrently, so two bundleInline calls hit getOrCreate with that same key, both see an empty cache, and both run the creator — each constructing a BundlerContext.
Even when they end up sharing one BundlerContext, the leak is sealed by a second race inside BundlerContext.#performBundle() (tools/esbuild/bundler-context.ts):
if(this.#esbuildContext){result=awaitthis.#esbuildContext.rebuild();}else{this.#esbuildContext =awaitcontext(this.#esbuildOptions);// await gap: both callers see undefinedresult=awaitthis.#esbuildContext.rebuild();}
Two concurrent bundle() calls both observe #esbuildContext === undefined, both call context(), and the second assignment overwrites the first. dispose() only disposes #esbuildContext (the second one); the first esbuild context is orphaned and never disposed, and esbuild.stop() is never called.
Instrumenting a real run (hooking BundlerContext.bundle/dispose) shows 432 contexts created, 431 disposed — exactly one orphan, whose bundleInline arguments are a pair of components with identical inline CSS. Differentiating the CSS by one byte balances the counts and the process exits cleanly.
Sequential bundling does not leak (the second call reuses #esbuildContext via rebuild()); the leak strictly requires the concurrency that parallel stylesheet transforms reliably hit, so it is deterministic.
Minimal Reproduction
Repo: (a stock @angular/build:unit-test setup plus two specs). A zip is attached below.
npm install
npx playwright install chromium
npm run test:hang # two components, IDENTICAL inline styles -> tests pass, then HANGS FOREVER
npm run test:ok # same, one byte different -> tests pass, EXITS code 0
The two scenarios live in separate source folders (src/hang/, src/ok/) with their own tsConfig/build configuration, because the unit-test builder bundles every spec matched by the build's tsConfig (not just those selected by the test include); a single shared build would let the duplicate-style spec poison the "ok" run too.
The only difference between hanging and passing is whether two inline styles strings are byte-identical:
// hang: ComponentA and ComponentB both use
styles: [`.box { width: 100px; height: 100px; }`]// ok: ComponentB differs by one byte
styles: [`.box { width: 100px; height: 100px; /* one byte different */ }`]
Expected: both commands exit with code 0 after the tests pass.
Actual: test:hang never exits; test:ok exits cleanly.
Exception or Error
(no error — tests pass, vitest prints its summary, then the process hangs)
Test Files 1 passed (1)
Tests 1 passed (1)
# process._getActiveHandles() shows one lingering handle:
ChildProcess .../@esbuild/<platform>/esbuild(.exe) --service=<ver> --ping
Your Environment
@angular/build: 22.0.0 (also reproduced on current main)
@angular/cli: 22.0.0
vitest: 4.1.0
@vitest/browser-playwright: 4.1.0
playwright: 1.58.2
Node.js: 22.22.3
OS: Windows 11 (also reproduces on Linux CI)
Runner: @angular/build:unit-test, vitest browser (Chromium), watch:false
Suggested fix: memoize the in-flight context() promise in BundlerContext.#performBundle() (clearing it in dispose()) so concurrent bundle() calls share one esbuild context — the same "create-once guard" shape as f102f81 (Initiate PostCSS only once). Making Cache.getOrCreate store the pending creator promise would additionally harden the ~16 other call sites, but is not sufficient on its own for this bug.
A "fix-it-in-userland" workaround is to ensure no two components in one file share byte-identical inline styles (change a selector, value, or add a comment).
Command
test
Is this a regression?
The previous version in which this bug was not present was
21.2.9
Description
When two component definitions in the same source file declare byte-identical inline
styles, the@angular/build:unit-testbuilder (vitest browser runner,watch:false) runs all tests, prints the passing summary, and then never exits. In CI the job is killed at its timeout.The hang is caused by a leaked esbuild build context.
process._getActiveHandles()at the hang shows a single non-stdio handle: aChildProcessfor…/@esbuild/…/esbuild --service=… --ping. esbuild's service child is a singleton kept alive by any live build context, so one orphaned context keeps the Node event loop alive forever. Killing only that esbuild child makes the runner exit immediately with code 0.Root cause
Two compounding check-then-act races on the concurrent component-stylesheet bundling path:
Cache.getOrCreate(tools/esbuild/cache.ts) is not concurrency-safe — itawait store.get(), and only onundefineddoes itawait creator()thenstore.set(). There is no in-flight promise memoization.Inline component styles are cached in a
MemoryCachekeyed on[language, sha256(content), filename](tools/esbuild/angular/component-stylesheets.ts→bundleInline,#inlineContexts). Two components in the same file with identical CSS produce the same key.The compiler plugin bundles component stylesheets concurrently, so two
bundleInlinecalls hitgetOrCreatewith that same key, both see an empty cache, and both run the creator — each constructing aBundlerContext.Even when they end up sharing one
BundlerContext, the leak is sealed by a second race insideBundlerContext.#performBundle()(tools/esbuild/bundler-context.ts):Two concurrent
bundle()calls both observe#esbuildContext === undefined, both callcontext(), and the second assignment overwrites the first.dispose()only disposes#esbuildContext(the second one); the first esbuild context is orphaned and never disposed, andesbuild.stop()is never called.Instrumenting a real run (hooking
BundlerContext.bundle/dispose) shows 432 contexts created, 431 disposed — exactly one orphan, whosebundleInlinearguments are a pair of components with identical inline CSS. Differentiating the CSS by one byte balances the counts and the process exits cleanly.Sequential bundling does not leak (the second call reuses
#esbuildContextviarebuild()); the leak strictly requires the concurrency that parallel stylesheet transforms reliably hit, so it is deterministic.Minimal Reproduction
Repo: (a stock
@angular/build:unit-testsetup plus two specs). A zip is attached below.The two scenarios live in separate source folders (
src/hang/,src/ok/) with their owntsConfig/build configuration, because the unit-test builder bundles every spec matched by the build's tsConfig (not just those selected by the testinclude); a single shared build would let the duplicate-style spec poison the "ok" run too.The only difference between hanging and passing is whether two inline
stylesstrings are byte-identical:Expected: both commands exit with code 0 after the tests pass.
Actual:
test:hangnever exits;test:okexits cleanly.Exception or Error
Your Environment
Anything else relevant?
ng build): that path (build-action/watch:false) is fixed in 22.0.0 and disposes its context correctly; this leak is one level down in the stylesheet bundler's per-context creation.close()vsexit(), fork-pool worker IPC handles): in browser mode there are no fork workers, and the only lingering handle here is the esbuild child, not a worker socket.ctx.exit()'s force-exit safety net would mask this hang, but the orphaned context would remain.context()promise inBundlerContext.#performBundle()(clearing it indispose()) so concurrentbundle()calls share one esbuild context — the same "create-once guard" shape as f102f81 (Initiate PostCSS only once). MakingCache.getOrCreatestore the pending creator promise would additionally harden the ~16 other call sites, but is not sufficient on its own for this bug.styles(change a selector, value, or add a comment).cc-12526-bug.zip