From 5aff760f6bb6f55fd097a11f39fb6979f77e5fc7 Mon Sep 17 00:00:00 2001 From: Aukevanoost Date: Mon, 14 Sep 2026 15:21:08 +0200 Subject: [PATCH] feat(builders): keep prebuilt entry points out of the TypeScript program A shared mapping may point at a library's build output rather than its source (`"@org/ui": ["dist/ui"]`), which is what stops ngtsc emitting deep relative imports into a mapped library and evaluating it twice. Such an entry point is already-compiled JS: esbuild loads it through the linker like any dependency, and listing it in the federation tsconfig's `files` only makes ngtsc reject it as a non-TypeScript root file. Core now marks every entry point `'source'` or `'package'`, so the decision is one it already made rather than something inferred here from a file extension. Nothing changes for existing setups: exposes and source mappings are both `'source'`, and a host whose mappings are all prebuilt still falls back to the app's own entry points to keep the program non-empty. Requires @softarc/native-federation with EntryPoint.kind. --- .../update-federation-tsconfig.spec.ts | 32 +++++++++++++++++-- .../esbuild/update-federation-tsconfig.ts | 7 +++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/tools/esbuild/update-federation-tsconfig.spec.ts b/src/tools/esbuild/update-federation-tsconfig.spec.ts index 83aaa11..8df1d93 100644 --- a/src/tools/esbuild/update-federation-tsconfig.spec.ts +++ b/src/tools/esbuild/update-federation-tsconfig.spec.ts @@ -7,8 +7,8 @@ import type { EntryPoint } from '@softarc/native-federation'; vi.mock('fs'); -function entry(fileName: string): EntryPoint { - return { fileName, outName: 'out.js' } as EntryPoint; +function entry(fileName: string, kind: EntryPoint['kind'] = 'source'): EntryPoint { + return { fileName, outName: 'out.js', kind }; } function written() { @@ -143,4 +143,32 @@ describe('updateFederationTsConfig', () => { expect(fs.writeFileSync).not.toHaveBeenCalled(); }); + + // A prebuilt mapping is compiled JS; ngtsc rejects it as a root file, and it does not need + // compiling in the first place. + it('leaves package entry points out of the program', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue('{}'); + + updateFederationTsConfig('/ws', 'tsconfig.federation.json', [ + entry('projects/app/src/bootstrap.ts'), + entry('dist/ui/fesm2022/ui.mjs', 'package'), + ]); + + expect(written().files).toEqual(['projects/app/src/bootstrap.ts']); + }); + + it('falls back to the app entry points when every mapping is prebuilt', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue('{}'); + + updateFederationTsConfig( + '/ws', + 'tsconfig.federation.json', + [entry('dist/ui/fesm2022/ui.mjs', 'package')], + ['projects/app/src/main.ts'] + ); + + expect(written().files).toEqual(['projects/app/src/main.ts']); + }); }); diff --git a/src/tools/esbuild/update-federation-tsconfig.ts b/src/tools/esbuild/update-federation-tsconfig.ts index 8413139..dc2b43e 100644 --- a/src/tools/esbuild/update-federation-tsconfig.ts +++ b/src/tools/esbuild/update-federation-tsconfig.ts @@ -32,7 +32,12 @@ export function updateFederationTsConfig( return path.relative(tsconfigDir, absolute).replace(/\\/g, '/'); }; - const resolved = entryPoints.map(ep => toTsConfigRelative(ep.fileName)); + // A 'package' entry point is already-compiled JS. esbuild loads it through the linker like + // any dependency, and putting it in `files` only makes ngtsc reject it as a non-TypeScript + // root file. + const resolved = entryPoints + .filter(ep => ep.kind !== 'package') + .map(ep => toTsConfigRelative(ep.fileName)); // A host without exposes or shared mappings has no entry points of its own; the app's // main.ts keeps the program from being empty.