rushstack icon indicating copy to clipboard operation
rushstack copied to clipboard

[api-extractor] Inconsistent rollups behaviour between moduleResolution

Open Lukinoh opened this issue 9 months ago • 1 comments

Summary

In my project, I have libraries that generate types using vite-plugin-dts which uses underneath api-extractor. When I migrated the tsconfig.json from moduleResolution: node to moduleResolution: bundler I realised that the output .d.ts was not identical.

Repro steps

The following repository is configured to use the api-extractor on the same code, but with 3 different moduleResolution: node, node16 and bundler.`

  • git clone [email protected]:Lukinoh/repro-api-extractor-bug (Stackblitz)
  • cd repro-api-extractor-bug
  • npm install
  • npm run build
  • Look at the dist folders, it contains for each moduleResolution:
    • The result of the tsc build
    • The extracted .d.ts
    • The diagnostics

The extracted .d.ts of node16 and bundler are different from node. I would have expected to have the content.

Expected result:

declare class ClassExample {
    field1: number;
    field2: string;
}

export declare const functions: ClassExample[];

export { }

Actual result:

import { ClassExample } from './hop/f1.ts';

export declare const functions: ClassExample[];

export { }

Standard questions

Please answer these questions to help us investigate your issue more quickly:

Question Answer
@microsoft/api-extractor version? 7.49.2
Operating system? Linux (WSL2)
API Extractor scenario? rollups (.d.ts)
Would you consider contributing a PR? No
TypeScript compiler version? 5.7.2
Node.js version (node -v)? 22.14.0

Lukinoh avatar Feb 12 '25 12:02 Lukinoh

I did some debugging, and I think I found the root cause (at least within the api-extractor app) of this issue. The reason the output appears this way is because the imported type's module is treated as external, which I believe is a bug in ExportAnalyzer._isExternalModulePath. Here's the main call flow that's occuring:

  • ExportAnalyzer._isExternalModulePath
    • calls TypeScriptInternals.getResolvedModule
      • calls ts.program.getResolvedModule
        • returns undefined
      • returns undefined
    • cannot resolve to module, so returns true

Why does ts.program.getResolvedModule return undefined?

  • In the resolvedModules underlying cache, the imported module is there but has a mode of 99 (ts.ModuleKind.ESNext)
    • Note: the cache only uses a mode with a moduleResolution of "bundler" or "node16", but it doesn't with "node", which is why we see a discrepancy
    • Note: the mode is also still ts.ModuleKind.ESNext, even if your tsconfig.json specifies a different target/module version (as far as I can tell)
  • However, we supply a mode of undefined, so it's a miss and the module is unable to be resolved, thus it is treated as an external module

Why is mode undefined?

  • The mode is retrieved by calling TypeScriptInternals.getModeForUsageLocation, but only if the specifier is a string literal.
  • This is determined by the ts.isStringLiteralLike function, which just matches ts.SyntaxKind.StringLiteral or ts.SyntaxKind.NoSubstitutionTemplateLiteral.
  • However, the specifier is a ts.SyntaxKind.Literal, so it doesn't pass the check.
  • This regression was likely caused by updating to TypeScript 5.7.2.

Unfortunately, what should be done here is now out of my depth. I'm not sure why the import specifier is not a string literal, and I'm not sure how to work around that. @octogonz could you weigh in on this?

Monkeylordz avatar Apr 12 '25 06:04 Monkeylordz