Skip to content

Compiler returning a Promise without async is treated as sync, its Promise ends up as the file source text #1906

Description

@reizam

Prerequisites

Reproduction

No StackBlitz — the reproduction is a fixture in this repo, which seemed more direct. Two fixtures, identical except for one async keyword.

fixtures/compilers/promise-sync/:

// package.json
{ "name": "@fixtures/compilers-promise-sync", "dependencies": { "dep": "*" } }
// knip.ts
export default { compilers: { foo: (text: string) => Promise.resolve(text) } };
// index.ts
import './styles.foo';
// styles.foo
import _$0 from 'dep';

fixtures/compilers/promise-async/ is the same with foo: async (text: string) => text.

compiler result
async (text) => text dep correctly seen as used — 0 issues
(text) => Promise.resolve(text) dep reported as an unused dependency

Description of the issue

A compiler that returns a Promise without being declared async is classified as sync, and its Promise object ends up cached as the file's source text.

isAsyncCompiler only checks the function constructor:

// src/compilers/index.ts:17
// TODO This does not detect functions returning a promise (just the async keyword)
const isAsyncCompiler = (fn?: CompilerSync | CompilerAsync) =>
  fn ? fn.constructor.name === 'AsyncFunction' : false;

so partitionCompilers routes it into syncCompilers, and SourceFileManager.readFile calls it without awaiting:

// src/typescript/SourceFileManager.ts:37-39
const compiled = compiler ? compiler(contents, filePath) : contents;
this.sourceTextCache.set(filePath, compiled);

TypeScript then receives a Promise object as file contents. Every import in that file disappears and the dependencies it referenced are reported unused — no error, no warning.

What makes this easy to walk into is that the public type allows both forms:

// src/compilers/types.ts:4
export type CompilerAsync = (source: string, path: string) => Promise<string>;

TypeScript doesn't distinguish async (t) => t from (t) => Promise.resolve(t), so a correctly typed compiler silently breaks depending on how it's written. Delegating compilers like (text) => transpiler.transform(text) land on the broken side.

I see three directions and didn't want to guess:

  1. Detect a thenable on first call and reclassify the extension as async — correct in all cases, but readFile is synchronous by design, so this is the invasive one.
  2. Throw a clear error from readFile when the compiler returns a non-string: "compiler for .foo returned a Promise; declare it async or use asyncCompilers". Doesn't fix it, but turns a silent false positive into something actionable. My preference.
  3. Document the limitation on the compilers page.

asyncCompilers is of course a valid workaround, but compilers advertises auto-detection and fails quietly here.

Happy to send a PR with the fixture and a regression test once you've picked a direction.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions