Skip to content

Commit 560ed8d

Browse files
authored
fix: handle all type-only imports by piping TS imports (#406)
* fix: handle all type-only imports by piping TS imports - `result.references` is populated by `ts.preProcessFile`; i.e. this is TS discovering all imports, instead of Rollup - TS's imports include type-only files as TS understands those (whereas they aren't emitted in the JS for Rollup to see, since, well, they produce no JS) - so we can pipe all these through Rollup's `this.resolve` and `this.load` to make them go through Rollup's `resolveId` -> `load` -> `transform` hooks - this makes sure that other plugins on the chain get to resolve/transform them as well - and it makes sure that we run the same code that we run on all other files on type-only ones too - for instance: adding declarations, type-checking, setting them as deps in the cache graph, etc - yay recursion! - also add check for circular references b/c of this recursion (which Rollup docs confirm is necessary, per in-line comment) - and Rollup ensures that there is no perf penalty if a regular file is processed this way either, as it won't save the hook results when it appears in JS (i.e. Rollup's module graph) - we are checking more files though, so that in and of itself means potential slowdown for better correctness - add a test for this that uses a `tsconfig` `files` array, ensuring that the `include` workaround won't cover these type-only files - this test fails without the new code added to `index` in this commit - also add another file, `type-only-import-import`, to the `no-errors` fixture to ensure that we're not just checking imports one level deep, and actually going through type-only imports of type-only imports as well - the declaration check for this will fail if type-only imports are not handled recursively - an initial version of this fix that I had that didn't call `this.load` failed this check - refactor(test): make the integration tests more resilient to output ordering changes - due to the eager calls to `this.load`, the ordering of declaration and declaration map outputs in the bundle changed - and bc TS's default ordering of imports seems to differ from Rollup's - note that this only changed the order of the "bundle output" object -- which Rollup doesn't guarantee ordering of anyway - all files are still in the bundle output and are still written to disk - for example, the `watch` tests did not rely on this ordering and as such did not need to change due to the ordering change - create a `findName` helper that will search the `output` array instead, ensuring that most ordering does not matter - we do still rely on `output[0]` being the bundled JS (ESM) file, however - refactor(test): go through a `files` array for tests that check for multiple files instead of listing out each individual check - this makes the tests more resilient to fixture changes as well (i.e. addition / deletion of files) - create `no-errors.ts` that exports a list of files for this fixture - didn't need to do the same for `errors.ts` as of yet; may do so in the future though * move type-only recursion to after declarations are added to the `declarations` dict - preserve some ordering and simplify future debugging - also fix lint issue, `let modules` -> `const modules` - I previously changed it (while WIP), but now it's static/never reassigned, so can use `const` * rewrite the core logic with a `for...of` loop instead - simpler to follow than `map` + `filter` + `Promise.all` - might(?) be faster without `Promise.all` as well as more can happen async without waiting - (I'm not totally sure of the low-level implementation of async to know for sure though) * add comment about normalization
1 parent c6be0eb commit 560ed8d

File tree

9 files changed

+92
-46
lines changed

9 files changed

+92
-46
lines changed

__tests__/integration/errors.spec.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { normalizePath as normalize } from "@rollup/pluginutils";
55
import * as fs from "fs-extra";
66

77
import { RPT2Options } from "../../src/index";
8-
import * as helpers from "./helpers";
8+
import { findName, genBundle as genBundleH } from "./helpers";
99

1010
// increase timeout to 15s for whole file since CI occassionally timed out -- these are integration and cache tests, so longer timeout is warranted
1111
jest.setTimeout(15000);
@@ -21,7 +21,7 @@ afterAll(async () => {
2121

2222
async function genBundle(relInput: string, extraOpts?: RPT2Options, onwarn?: Mock) {
2323
const input = local(`fixtures/errors/${relInput}`);
24-
return helpers.genBundle({
24+
return genBundleH({
2525
input,
2626
tsconfig: local("fixtures/errors/tsconfig.json"),
2727
testDir,
@@ -41,10 +41,11 @@ test("integration - semantic error - abortOnError: false / check: false", async
4141
const { output: output2 } = await genBundle("semantic.ts", { check: false }, onwarn);
4242
expect(output).toEqual(output2);
4343

44-
expect(output[0].fileName).toEqual("index.js");
45-
expect(output[1].fileName).toEqual("semantic.d.ts");
46-
expect(output[2].fileName).toEqual("semantic.d.ts.map");
47-
expect(output.length).toEqual(3); // no other files
44+
const files = ["index.js", "semantic.d.ts", "semantic.d.ts.map"];
45+
files.forEach(file => {
46+
expect(findName(output, file)).toBeTruthy();
47+
});
48+
expect(output.length).toEqual(files.length); // no other files
4849
expect(onwarn).toBeCalledTimes(1);
4950
});
5051

@@ -80,11 +81,10 @@ test("integration - type-only import error - abortOnError: false / check: false"
8081
}, onwarn);
8182
expect(output).toEqual(output2);
8283

83-
expect(output[0].fileName).toEqual("index.js");
84-
expect(output[1].fileName).toEqual("import-type-error.d.ts");
85-
expect(output[2].fileName).toEqual("import-type-error.d.ts.map");
86-
expect(output[3].fileName).toEqual("type-only-import-with-error.d.ts");
87-
expect(output[4].fileName).toEqual("type-only-import-with-error.d.ts.map");
88-
expect(output.length).toEqual(5); // no other files
84+
const files = ["index.js", "import-type-error.d.ts", "import-type-error.d.ts.map", "type-only-import-with-error.d.ts.map", "type-only-import-with-error.d.ts.map"];
85+
files.forEach(file => {
86+
expect(findName(output, file)).toBeTruthy();
87+
});
88+
expect(output.length).toEqual(files.length); // no other files
8989
expect(onwarn).toBeCalledTimes(1);
9090
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export const filesArr = [
2+
"index.js",
3+
"index.d.ts",
4+
"index.d.ts.map",
5+
"some-import.d.ts",
6+
"some-import.d.ts.map",
7+
"type-only-import.d.ts",
8+
"type-only-import.d.ts.map",
9+
"type-only-import-import.d.ts",
10+
"type-only-import-import.d.ts.map",
11+
];

__tests__/integration/fixtures/no-errors/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@ import { difference } from "./some-import";
66
export const diff2 = difference; // add an alias so that this file has to change when the import does (to help test cache invalidation etc)
77

88
export { difference } from "./some-import"
9-
export type { num } from "./type-only-import"
9+
export type { num, num2 } from "./type-only-import"
1010

1111
export { identity } from "./some-js-import"
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export type numb = number;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
1+
import type { numb } from "./type-only-import-import";
2+
13
export type num = number;
4+
export type num2 = numb;

__tests__/integration/helpers.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { rollup, watch, RollupOptions, OutputOptions, OutputAsset, RollupWatcher } from "rollup";
1+
import { rollup, watch, RollupOptions, OutputOptions, RollupOutput, OutputAsset, RollupWatcher } from "rollup";
22
import * as path from "path";
33

44
import rpt2, { RPT2Options } from "../../src/index";
@@ -72,3 +72,8 @@ export async function watchBundle (inputArgs: Params) {
7272
await watchEnd(watcher); // wait for build to end before returning, similar to genBundle
7373
return watcher;
7474
}
75+
76+
export function findName (output: RollupOutput['output'], name: string) {
77+
// type-cast to simplify type-checking -- [0] is always chunk, rest are always asset in our case
78+
return output.find(file => file.fileName === name) as OutputAsset;
79+
}

__tests__/integration/no-errors.spec.ts

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { jest, afterAll, test, expect } from "@jest/globals";
22
import * as path from "path";
33
import * as fs from "fs-extra";
4-
import { OutputAsset } from "rollup";
54
import { normalizePath as normalize } from "@rollup/pluginutils";
65

76
import { RPT2Options } from "../../src/index";
8-
import * as helpers from "./helpers";
7+
import { filesArr } from "./fixtures/no-errors";
8+
import { findName, genBundle as genBundleH } from "./helpers";
99

1010
// increase timeout to 15s for whole file since CI occassionally timed out -- these are integration and cache tests, so longer timeout is warranted
1111
jest.setTimeout(15000);
@@ -17,7 +17,7 @@ const fixtureDir = local("fixtures/no-errors");
1717
afterAll(() => fs.remove(testDir));
1818

1919
async function genBundle(relInput: string, extraOpts?: RPT2Options) {
20-
return helpers.genBundle({
20+
return genBundleH({
2121
input: `${fixtureDir}/${relInput}`,
2222
tsconfig: `${fixtureDir}/tsconfig.json`,
2323
testDir,
@@ -33,36 +33,45 @@ test("integration - no errors", async () => {
3333
const { output: outputWithCache } = await genBundle("index.ts");
3434
expect(output).toEqual(outputWithCache);
3535

36-
expect(output[0].fileName).toEqual("index.js");
37-
expect(output[1].fileName).toEqual("index.d.ts");
38-
expect(output[2].fileName).toEqual("index.d.ts.map");
39-
expect(output[3].fileName).toEqual("some-import.d.ts");
40-
expect(output[4].fileName).toEqual("some-import.d.ts.map");
41-
expect(output[5].fileName).toEqual("type-only-import.d.ts");
42-
expect(output[6].fileName).toEqual("type-only-import.d.ts.map");
43-
expect(output.length).toEqual(7); // no other files
36+
const files = filesArr;
37+
files.forEach(file => {
38+
expect(findName(output, file)).toBeTruthy();
39+
});
40+
expect(output.length).toEqual(files.length); // no other files
4441

4542
// JS file should be bundled by Rollup, even though rpt2 does not resolve it (as Rollup natively understands ESM)
4643
expect(output[0].code).toEqual(expect.stringContaining("identity"));
4744

4845
// declaration map sources should be correctly remapped (and not point to placeholder dir, c.f. https://github.com/ezolenko/rollup-plugin-typescript2/pull/221)
49-
const decMapSources = JSON.parse((output[2] as OutputAsset).source as string).sources;
46+
const decMap = findName(output, "index.d.ts.map");
47+
const decMapSources = JSON.parse(decMap.source as string).sources;
5048
const decRelPath = normalize(path.relative(`${testDir}/dist`, `${fixtureDir}/index.ts`));
5149
expect(decMapSources).toEqual([decRelPath]);
5250
});
5351

52+
test("integration - no errors - using files list", async () => {
53+
const { output } = await genBundle("index.ts", { tsconfigOverride: { files: ["index.ts"] } });
54+
55+
// should still have the type-only import and type-only import import!
56+
const files = filesArr;
57+
files.forEach(file => {
58+
expect(findName(output, file)).toBeTruthy();
59+
});
60+
expect(output.length).toEqual(files.length); // no other files
61+
});
62+
5463
test("integration - no errors - no declaration maps", async () => {
5564
const noDeclarationMaps = { compilerOptions: { declarationMap: false } };
5665
const { output } = await genBundle("index.ts", {
5766
tsconfigOverride: noDeclarationMaps,
5867
clean: true,
5968
});
6069

61-
expect(output[0].fileName).toEqual("index.js");
62-
expect(output[1].fileName).toEqual("index.d.ts");
63-
expect(output[2].fileName).toEqual("some-import.d.ts");
64-
expect(output[3].fileName).toEqual("type-only-import.d.ts");
65-
expect(output.length).toEqual(4); // no other files
70+
const files = filesArr.filter(file => !file.endsWith(".d.ts.map"));
71+
files.forEach(file => {
72+
expect(findName(output, file)).toBeTruthy();
73+
});
74+
expect(output.length).toEqual(files.length); // no other files
6675
});
6776

6877

@@ -88,12 +97,15 @@ test("integration - no errors - allowJs + emitDeclarationOnly", async () => {
8897
},
8998
});
9099

91-
expect(output[0].fileName).toEqual("index.js");
92-
expect(output[1].fileName).toEqual("some-js-import.d.ts");
93-
expect(output[2].fileName).toEqual("some-js-import.d.ts.map");
94-
expect(output.length).toEqual(3); // no other files
100+
const files = ["index.js", "some-js-import.d.ts", "some-js-import.d.ts.map"];
101+
files.forEach(file => {
102+
expect(findName(output, file)).toBeTruthy();
103+
});
104+
expect(output.length).toEqual(files.length); // no other files
95105

96106
expect(output[0].code).toEqual(expect.stringContaining("identity"));
97107
expect(output[0].code).not.toEqual(expect.stringContaining("sum")); // no TS files included
98-
expect("source" in output[1] && output[1].source).toEqual(expect.stringContaining("identity"));
108+
109+
const dec = findName(output, "some-js-import.d.ts");
110+
expect(dec.source).toEqual(expect.stringContaining("identity"));
99111
});

__tests__/integration/watch.spec.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as path from "path";
33
import * as fs from "fs-extra";
44

55
import { RPT2Options } from "../../src/index";
6+
import { filesArr } from "./fixtures/no-errors";
67
import * as helpers from "./helpers";
78

89
// increase timeout to 15s for whole file since CI occassionally timed out -- these are integration and cache tests, so longer timeout is warranted
@@ -36,15 +37,6 @@ test("integration - watch", async () => {
3637
const distPath = `${testDir}/dist/index.js`;
3738
const decPath = `${distDir}/index.d.ts`;
3839
const decMapPath = `${decPath}.map`;
39-
const filesArr = [
40-
"index.js",
41-
"index.d.ts",
42-
"index.d.ts.map",
43-
"some-import.d.ts",
44-
"some-import.d.ts.map",
45-
"type-only-import.d.ts",
46-
"type-only-import.d.ts.map",
47-
];
4840

4941
const watcher = await watchBundle(srcPath);
5042

src/index.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const typescript: PluginImpl<RPT2Options> = (options) =>
3333
let documentRegistry: tsTypes.DocumentRegistry; // keep the same DocumentRegistry between watch cycles
3434
let cache: TsCache;
3535
let noErrors = true;
36+
let transformedFiles: Set<string>;
3637
const declarations: { [name: string]: { type: tsTypes.OutputFile; map?: tsTypes.OutputFile } } = {};
3738
const checkedFiles = new Set<string>();
3839

@@ -150,6 +151,9 @@ const typescript: PluginImpl<RPT2Options> = (options) =>
150151

151152
cache = new TsCache(pluginOptions.clean, pluginOptions.objectHashIgnoreUnknownHack, servicesHost, pluginOptions.cacheRoot, parsedConfig.options, rollupOptions, parsedConfig.fileNames, context);
152153

154+
// reset transformedFiles Set on each watch cycle
155+
transformedFiles = new Set<string>();
156+
153157
// printing compiler option errors
154158
if (pluginOptions.check) {
155159
const diagnostics = convertDiagnostic("options", service.getCompilerOptionsDiagnostics());
@@ -203,8 +207,10 @@ const typescript: PluginImpl<RPT2Options> = (options) =>
203207
return null;
204208
},
205209

206-
transform(code, id)
210+
async transform(code, id)
207211
{
212+
transformedFiles.add(id); // note: this does not need normalization as we only compare Rollup <-> Rollup, and not Rollup <-> TS
213+
208214
if (!filter(id))
209215
return undefined;
210216

@@ -245,6 +251,22 @@ const typescript: PluginImpl<RPT2Options> = (options) =>
245251

246252
addDeclaration(id, result);
247253

254+
// handle all type-only imports by resolving + loading all of TS's references
255+
// Rollup can't see these otherwise, because they are "emit-less" and produce no JS
256+
if (result.references) {
257+
for (const ref of result.references) {
258+
if (ref.endsWith(".d.ts"))
259+
continue;
260+
261+
const module = await this.resolve(ref, id);
262+
if (!module || transformedFiles.has(module.id)) // check for circular references (per https://rollupjs.org/guide/en/#thisload)
263+
continue;
264+
265+
// wait for all to be loaded (otherwise, as this is async, some may end up only loading after `generateBundle`)
266+
await this.load({id: module.id});
267+
}
268+
}
269+
248270
// if a user sets this compilerOption, they probably want another plugin (e.g. Babel, ESBuild) to transform their TS instead, while rpt2 just type-checks and/or outputs declarations
249271
// note that result.code is non-existent if emitDeclarationOnly per https://github.com/ezolenko/rollup-plugin-typescript2/issues/268
250272
if (parsedConfig.options.emitDeclarationOnly)

0 commit comments

Comments
 (0)