Skip to content

fix: Spell the workspace root the way disk spells it - #126

Merged
Aukevanoost merged 5 commits into
mainfrom
issues/117
Aug 29, 2026
Merged

fix: Spell the workspace root the way disk spells it#126
Aukevanoost merged 5 commits into
mainfrom
issues/117

Conversation

@Aukevanoost

@Aukevanoost Aukevanoost commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Fixes symptom 2 of #117. Symptom 1 was core's half, in native-federation/native-federation-core#112merged, and shipped in @softarc/native-federation@4.5.0, which this PR now depends on.

Problem

Windows reports the same directory under whatever drive-letter case the caller used. Nx inherits workspaceRoot from the invoking shell (c:\…), while process.cwd() and esbuild's own working directory carry the case as stored on disk (C:\…). Nothing else in the toolchain notices, because Windows treats them as the same directory.

The angular-compiler plugin keys its emitted-file cache by paths derived from the TypeScript program, which follows the tsconfig path we build from workspaceRoot. Exposed modules are handed to esbuild as relative entry points, which esbuild resolves through its Go-side working directory. The two disagree by case, typeScriptFileCache.get(request) misses, and every exposed module fails with:

X [ERROR] File 'apps\kanban\src\app\features\kanban\kanban.component.ts' not found in
  TypeScript compilation. [plugin angular-compiler]

The message points at tsconfig files/include, which is a dead end — the file is in the program, under a differently-cased path. This is why a remote can build from one terminal and not from another on the same machine.

Fix

toDiskCase re-spells a root the way fs.realpathSync.native reports it, but only when the two differ by case alone. realpath also resolves symlinks, so accepting a broader difference would move npm-linked and pnpm workspaces off the path they were handed.

The .native variant is load-bearing: the JS realpathSync walks the components of the input string and rewrites only the ones that are symlinks, so it preserves the caller's casing and cannot fix this.

It is deliberately the same name, file and shape as core's utils/disk-case.ts — same guard, same reasoning — differing only in reaching fs directly where core reads disk through an io port. The adapter cannot simply take core's corrected root: it needs the root before it calls core, and hands the same context to Angular's builder.

Applied once per invocation at the builder boundary, so the four places reading context.workspaceRoot — including both esbuild bundlers, which reach it through the adapter's closure — inherit it with no further change. Two details:

  • The wrap sits inside runBuilder/runRemoteBuilder, not in createBuilder, because runBuilder is re-exported from internal.ts and callable directly.
  • Angular's own build receives the same context. The two halves compare each other's paths (watch sets, cache keys), so splitting the root between them would trade this bug for a new one.

The context is derived with Object.create rather than copied: the architect context's methods close over the original object. Nothing in this repo or in @angular/build spreads a builder context, so delegation is safe.

A second commit anchors workspace-root-relative entry points on that root, removing esbuild's working directory from the picture entirely. That one is hardening, not the fix — re-spelling the root is what makes the cache keys match. Anchoring on a mis-cased root would make the request equally wrong and mask the mismatch rather than remove it, so it is only sound on top of the first commit. Shared mappings arrive absolute already and are left alone.

Interaction with core#112

Core re-spells the root it is given and returns it on normalized.options.workspaceRoot. Without this PR, an adapter build would run with core's paths canonical and its own context.workspaceRoot still mis-cased — a split root inside one build. Correcting at the builder boundary means core is handed a root it finds already correct, so the two corrections compose rather than compete.

Core's port gained realpathNative; the adapter implements no IoPort, so that is not a break here.

Verification

Both symptoms now reproduce on a case-sensitive filesystem, so this no longer waits on a Windows machine. ng cannot drive it — getcwd(2) resolves symlinks, so the CLI always derives a canonical root — but Architect takes workspaceRoot as a plain string. Pointed at a sibling symlink whose name differs from its target by case alone (angular/SIMPLE -> simple), a build sits in exactly the reporter's position: cwd and esbuild canonical, only the root re-spelled. Two throwaway scripts drove it: one scheduling mfe1:build through Architect with that root, one calling core's normalizeFederationOptions directly.

Symptom 2, on angular-examples/angular/simple, project mfe1:

root adapter core result
canonical 22.1.1 4.4.1 baseline — 8 shared entries incl. @internal/logging
mis-cased 22.1.1 4.4.1 File 'projects/mfe1/src/app/app.component.ts' not found in TypeScript compilation — the reported error, verbatim
mis-cased this PR 4.4.1 byte-identical to baseline
mis-cased this PR 4.5.0 byte-identical to baseline (full output tree)
canonical this PR 4.5.0 byte-identical to baseline

host, mfe1, mfe2 and mfe3 all build clean at the mis-cased root on this PR.

Symptom 1, calling core's normalizeFederationOptions directly: 4.4.1 returns the mapping at a canonical root and [] at a mis-cased one — the silent drop, no error; 4.5.0 re-spells the root and keeps it.

Two things the matrix shows that the write-up above did not predict:

  • Row 3 keeps @internal/logging with pre-fix core 4.4.1. Correcting at the builder boundary lands upstream of core's normalize, so core is handed a root it finds already correct. Core#112 remains the fix for the esbuild adapter and for direct core consumers; on the adapter path it is defence in depth.
  • Symptom 1 is invisible in a remote build — symptom 2 fails it first. That matches the report: the silent drop was seen on a host, which exposes nothing and so has no cache miss to fail on.

The simulation is not exact, and a Windows confirmation is still worth having. A symlink makes TypeScript canonicalize module paths in ways Windows would not. A root differing by more than case (simple2 -> simple) therefore still fails on this branch — identically to how it fails before it, since toDiskCase corrects case alone by design. What transfers is the case-only path, where the input to the corrected code is indistinguishable from the reported one and the failure reproduced verbatim.

Earlier, on angular-examples/angular/nx (4 projects, @internal/* mapping, cold NF cache): remoteEntry.json, importmap.json and the full output file list are byte-identical for host and mfe1–3, both with this PR alone and with core#112 alongside. mfe1 covers both branches of the entry-point change at once — ./Component is workspace-root-relative and takes the new path.join, @internal/logging arrives absolute and takes the passthrough.

Deliberately out of scope

Case-insensitive path comparison at the individual call sites. It is whack-a-mole across the adapter, core and Angular's own typeScriptFileCache, which we do not own, and it only fixes the match — the mis-cased path still flows onward as a map key.

Windows reports the same directory under whatever drive-letter case the caller
used, so the root Nx inherits from the invoking shell can differ by case alone
from the one esbuild's own working directory and process.cwd() produce. Every
path derived from it is then compared as a plain string against paths derived
from the other, most damagingly in the angular-compiler plugin's emitted-file
cache: its keys follow the TypeScript program (and so the workspace root) while
its lookups follow esbuild, so every exposed module reports "File ... not found
in TypeScript compilation" and points at tsconfig files/include, which is a dead
end. This is why a remote builds from one terminal and not another on the same
machine.

toCanonicalCase re-spells a root the way fs.realpathSync.native reports it, but
only when the two differ by case alone. realpath also resolves symlinks, and
adopting a broader difference would move npm-linked and pnpm workspaces off the
path they were handed. The .native variant is load-bearing: the JS realpathSync
walks the components of the input string and rewrites only the ones that are
symlinks, so it preserves the caller's casing and cannot fix this.

Applied once per invocation, at the builder boundary, so the four places that
read context.workspaceRoot -- including both esbuild bundlers, which reach it
through the adapter's closure -- inherit it with no further change. The wrap
sits inside runBuilder rather than in createBuilder because runBuilder is
re-exported from internal.ts and callable directly. Angular's own build gets
the same context: the two halves compare each other's paths (watch sets, cache
keys), so splitting the root between them would trade this bug for another.

Refs #117
Core hands exposes over workspace-root-relative, and esbuild resolves relative
entry points through its own Go-side working directory, which need not be the
root the TypeScript program -- and with it the compiler plugin's cache keys --
was built from. Joining them onto the workspace root removes that second
resolver from the picture.

Hardening rather than the fix: re-spelling the root is what makes the cache
keys match. Anchoring on a mis-cased root would make the request equally wrong
and mask the mismatch instead of removing it, so this is only sound on top of
the previous commit. Shared mappings arrive absolute already and are left
alone.

Refs #117
Core landed the same rule as `toDiskCase` in `utils/disk-case.ts`; this side
called it `toCanonicalCase` in `utils/canonical-workspace-root.ts`. Same guard,
same `realpathSync.native` reasoning, two names to reconcile whenever the two
implementations are read against each other.

Also adopts core's `real === p` early return, so an already-correct root is
handed back untouched rather than through `path.normalize`. Behaviour is
unchanged -- every consumer joins onto the root -- but the two functions now
read line for line.

Refs #117
The core half of #117 shipped in the 4.5.0 stable release, so the prerelease
spelling now names a version no install resolves to: semver ranks 4.5.0 above
4.5.0-next.1, and `~4.5.0-next.1` already admits it.

Refs #117
@Aukevanoost
Aukevanoost marked this pull request as ready for review August 29, 2026 08:08
@Aukevanoost
Aukevanoost merged commit 73e0702 into main Aug 29, 2026
2 checks passed
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