Skip to content

fix(esbuild): keep the node_modules fallback for types in context tsconfigs (#166) - #168

Open
JasinRefiku wants to merge 2 commits into
native-federation:mainfrom
JasinRefiku:fix/context-tsconfig-type-roots
Open

JasinRefiku wants to merge 2 commits into
native-federation:mainfrom
JasinRefiku:fix/context-tsconfig-type-roots

Conversation

@JasinRefiku

@JasinRefiku JasinRefiku commented Oct 5, 2026 •

Copy link
Copy Markdown

Fixes #166.

Problem

writeContextTsConfig writes one tsconfig per build context into the cache dir. It extends the federation tsconfig and pins compilerOptions.typeRoots to ts.getEffectiveTypeRoots(...). That pin came in with #138 (65a0dc3): without an explicit typeRoots, TypeScript builds the default roots from the leaf config's directory, which is now node_modules/.cache/native-federation/tsconfig/. A project-local projects/mfe1/node_modules/@types (a pnpm workspace package, say) would then drop out.

The pin also switches off TypeScript's node_modules fallback for types entries. In resolveTypeReferenceDirective (node_modules/typescript/lib/typescript.js, TS 6.0.3):

  • Primary lookup goes through the type roots and only finds @types/<name>.
  • Secondary lookup walks node_modules up from the containing directory. It is skipped when options.typeRoots is set and the containing file is __inferred type names__.ts, which is the case for every types entry:
    if (!options.typeRoots || !endsWith(containingFile, inferredTypesContainingFile)) {
      // loadModuleFromNearestNodeModulesDirectory(...)
    } else {
      // "Resolving type reference directive for program that specifies custom typeRoots, skipping lookup in 'node_modules' folder."
    }

So a package that ships its own typings, like @angular/localize ("typings": "./types/localize.d.ts"), never resolves: TS2688 for the type library, then TS2304 for each $localize. TypeScript 5.9 has the same check, so this is not specific to TS 6. The adapter just sets typeRoots on every build.

Change

The type roots stay pinned as before. readFederationTsConfig now also resolves each types entry the way the federation tsconfig's own program does: ts.resolveTypeReferenceDirective with its options, from __inferred type names__.ts in its directory. The context tsconfig gets the absolute path of the resolved file, which TypeScript loads directly. typeRoots is not widened, so /// <reference types> lookups elsewhere in the program behave as they do today.

  • * and entries that don't resolve are written unchanged. With explicit typeRoots, TypeScript skips the fallback for the federation tsconfig as well, so an entry resolves or fails the same way in both.
  • No types: nothing is written, and the context tsconfig is identical to today's.
  • No type root on disk: TypeScript loads an absolute types entry only through a type root that exists (primaryLookup skips missing roots, and the fallback is off because typeRoots is set). In a workspace without any node_modules/@types, every entry would fail with TS2688. In that case the context tsconfig's own directory is appended as a type root. It holds only the generated .json files, so it adds no type libraries, including under *.

This also covers what the first version of this PR listed as known limits: packages typed only through exports, and relative entries such as "./src/types".

Alternatives considered

  • Append the node_modules folders to typeRoots (the first version of this PR). This changes every /// <reference types> lookup in the program, dependencies included. Under pnpm a hoisted copy of another version can win. It also misses exports-only and relative entries.
  • Only write typeRoots when the federation tsconfig sets them (the issue's first suggestion). This brings back what Two federation build contexts after core #145 #138 guarded against: anything in a node_modules between the federation tsconfig and the workspace root is missed.
  • Always append the workspace node_modules (the issue's workaround). This breaks * on TS 6. On TS 5.x with no types set, it auto-includes every package as a type library.

Testing

  • pnpm test, pnpm typecheck, pnpm lint and knip pass, apart from one spec that also fails on main. sourcemap-ignorelist-plugin.spec.ts › "marks node_modules sources of a bundle emitted into the federation cache" fails on macOS because os.tmpdir() is under the /var → /private/var symlink. It fails the same way on d2fb764.
  • Specs in write-context-tsconfig.spec.ts, under "type libraries in types". Each builds a real ts.createProgram from the generated config and checks its pre-emit diagnostics:
    • A self-typed @acme/localize (typings in its own package.json) in the workspace node_modules resolves. Before the fix this gave the issue's TS2688 + TS2304.
    • The same package in the federation tsconfig's own projects/mfe1/node_modules resolves.
    • With no node_modules/@types anywhere, the context tsconfig's directory is added as a type root and the entry resolves.
    • A package with typings only under exports resolves (moduleResolution: bundler).
    • A relative entry (./src/env) resolves from the federation tsconfig, not the cache dir.
    • @types packages in the workspace and in projects/mfe1/node_modules/@types still resolve.
    • * next to a self-typed package works. It failed before, and with the first version of this PR.
    • Explicit typeRoots are kept, and an entry they don't resolve is written unchanged.
    • Without types, none are written.
  • The new specs fail without the fix: 6 of them if types isn't written, and 4 if the extra type root is left out. The existing Two federation build contexts after core #145 #138 specs are unchanged and pass.

…sconfigs (native-federation#166)

The per-context tsconfig pins typeRoots to the federation tsconfig's default roots, because
TypeScript derives them from the leaf config's directory, which is now the cache dir. With
typeRoots set, TypeScript skips its node_modules lookup for `types` entries, so packages that
ship their own typings, like @angular/localize, failed with TS2688 and every `$localize` with
TS2304.

When an entry resolves only through that fallback for the federation tsconfig, the node_modules
folders it walks are now appended after the @types roots, nearest first. Nothing changes when
typeRoots are explicit, when every entry resolves from @types, or next to the `*` wildcard,
which would otherwise take every package in node_modules as a type library.

Fixes native-federation#166

@Aukevanoost Aukevanoost left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi thanks for the contribution! however, rather than widening typeRoots, write the resolved types into the context tsconfig as absolute paths. TypeScript loads those directly, so typeRoots stays as it is:

function resolveTypes(ts: typeof TypeScript, options: TypeScript.CompilerOptions, federationTsConfig: string) {
  // What createProgram uses as the containing file for `types`.
  const containingFile = path.join(path.dirname(federationTsConfig), '__inferred type names__.ts');
  return options.types?.map(name =>
    name === '*'
      ? name
      : (ts.resolveTypeReferenceDirective(name, containingFile, options, ts.sys)
          .resolvedTypeReferenceDirective?.resolvedFileName ?? name)
  );
}
compilerOptions: { typeRoots: federation.typeRoots.map(toPosix), types: federation.types?.map(toPosix) },

This will help because widening typeRoots changes every /// <reference types> lookup in the program, including ones in dependencies. Under pnpm that can swap in a hoisted package of another version. It also puts all @types roots before all node_modules roots, which isn't TypeScript's nearest-first order.It also covers cases the PR doesn't at the moment. Finally, there's no special case for explicit typeRoots or *. Unresolved entries pass through unchanged, and nothing is written when types is unset.

Also, try to keep the diagnose specs and add the exports-only and relative cases. Also cut the comment down to one line that references #166 (.claude/rules/comments.md).

@Aukevanoost Aukevanoost added the question Further information is requested label Oct 17, 2026
…tive-federation#166)

Resolve each `types` entry from the federation tsconfig's directory and
write the resolved file into the context tsconfig, so typeRoots stay
pinned. Covers packages typed only through `exports` and relative
entries. When none of the pinned type roots exists on disk, add the
context tsconfig's directory as one, since TypeScript loads absolute
`types` entries only through an existing type root.
@JasinRefiku

Copy link
Copy Markdown
Author

Switched to resolving types as you suggested (d3da91e). typeRoots stays pinned, * and unresolved entries pass through, and nothing is written when types is unset. The diagnose specs are kept, with new exports-only and relative cases, and the comment is down to one line referencing #166.

One addition: TypeScript loads an absolute types entry only through a type root that exists on disk (primaryLookup skips missing roots, and the fallback is off because typeRoots is set). In a workspace without any node_modules/@types, every entry failed with TS2688, on TS 5.9 and 6.0. When none of the pinned roots exists, the context tsconfig's own directory is now appended as a type root. It only holds the generated .json files, so it adds no type libraries, also under *. There's a spec for it.

I've updated the PR description to match.

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

Labels

question Further information is requested

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Context tsconfig sets explicit typeRoots, so packages in types that ship their own typings (e.g. @angular/localize) stop resolving on TypeScript 6

2 participants