Skip to content

Commit 89fa680

Browse files
feat(typescript): process .js when allowJs is enabled (#1920)
* feat(typescript): honor allowJs and transform .js files\n\n- Expand default include to JS/JSX/MJS/CJS when is enabled\n- Avoid TS5055 by redirecting JS emit to a temp outDir when none is set\n- Add tests for downleveling JS (direct entry and TS->JS import)\n- Document behavior in README * feat(typescript): auto-include JS when allowJs is set; use temp outDir and clean up\n\n- Use brace expansion in default include glob\n- Create per-build temp outDir under os.tmpdir(), exclude it from filter, and remove on build end\n- Document behavior in README * fix(typescript): exclude effective outDir from filter to avoid re-processing outputs\n\n- Exclude user-configured or auto temp outDir from the plugin filter\n- Guard against excluding sources when outDir contains the filter root (e.g., outDir='.' with rootDir='src')\n\nImplements review feedback to avoid feedback loops with allowJs and user outDir. * fix(typescript): use path.relative for robust outDir containment check - Replace brittle startsWith-based check with path.relative to detect containment - Address ESLint prefer-template with template literal Refs #1920 * test(typescript): add regression ensuring user-configured outDir is excluded with allowJs * fix(typescript): gate temp outDir cleanup to non-watch; cleanup in closeWatcher; harden outDir exclusion\n\n- Clean temp outDir only when not watching; perform final cleanup in closeWatcher to avoid watch-mode churn\n- Robust containment check for excluding outDir using drive-root equality + path.relative + !isAbsolute (handles Windows cross-drive)\n- When filterRoot=false, use CWD fallback for containment to avoid over-excluding sources (e.g., outDir='.')\n- Docs: clarify cleanup timing in README (non-watch vs watch) * test(typescript): harden user outDir exclusion test (robust path check, guaranteed cleanup) * fix(typescript): robust outDir containment; gate temp outDir cleanup in watch mode\n\n- Guard containment with cross-drive root check and absolute-relative detection\n- Skip over-exclusion when filterRoot is false by falling back to process.cwd()\n- Delete temp outDir only for non-watch builds; clean up in closeWatcher for watch\n- Docs: clarify effective outDir exclusion and watch-mode cleanup timing\n\nRefs #1920 # Conflicts: # packages/typescript/README.md # packages/typescript/src/index.ts * test(typescript): make isInside helper treat equal path as inside; mirror plugin containment semantics * test(typescript): remove REBUILD_WITH_WATCH_OFF from incremental-watch-off fixture Drop the unused export from the fixture. It was only there to force a change and is no longer needed. No behavior change; keeps the test fixture minimal and avoids pointless noise. * fix(typescript): guard string-only filterRoot base; close watch program on shutdown\n\n- Treat only string values of as a base path; fall back to tsconfig otherwise to avoid resolving boolean to 'true'\n- Align 's option with the same guard to prevent passing non-string truthy values\n- Close the TypeScript watch program in to release resources in watch mode\n\nRefs #1920 * fix(typescript): normalize Windows drive roots in containment; exclude node_modules by default when auto‑including JS\n\n- Normalize drive‑letter case when comparing roots in outDir containment (win32)\n- When allowJs expands include and user did not set include/exclude, add '**/node_modules/**' to exclude to avoid transforming third‑party code\n\nRefs #1920 * docs(typescript): note default node_modules exclusion when allowJs expands include * fix(typescript): default filter resolve to cwd when rootDir unset\n\n- Align runtime with README for default: when is not a string and resolution is enabled\n- Prevents from receiving base in edge configs\n\nRefs #1920 * refactor(typescript): centralize temp outDir cleanup into a helper - Deduplicate try/catch cleanup in buildEnd and closeWatcher via local function - No behavior change; preserves watch vs non-watch cleanup semantics Refs #1920 * refactor(typescript): unify program close + temp outDir cleanup via helper - Add local closeProgramAndCleanup() that always cleans in finally - Use in buildEnd (non-watch) and closeWatcher (watch) Refs #1920 --------- Co-authored-by: CharlieHelps <[email protected]>
1 parent 8851ea4 commit 89fa680

File tree

9 files changed

+210
-7
lines changed

9 files changed

+210
-7
lines changed

packages/typescript/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,11 @@ A [picomatch pattern](https://github.com/micromatch/picomatch), or array of patt
7777
Type: `String` | `Array[...String]`<br>
7878
Default: `null`
7979

80-
A [picomatch pattern](https://github.com/micromatch/picomatch), or array of patterns, which specifies the files in the build the plugin should operate on. By default all `.ts` and `.tsx` files are targeted.
80+
A [picomatch pattern](https://github.com/micromatch/picomatch), or array of patterns, which specifies the files in the build the plugin should operate on. By default all `.ts` and `.tsx` files are targeted. If your `tsconfig.json` (or plugin `compilerOptions`) sets `allowJs: true`, the default include expands to also cover `.js`, `.jsx`, `.mjs`, and `.cjs` files so that JavaScript sources are downleveled by TypeScript as well.
81+
82+
> Note: When `allowJs` is enabled and no `outDir` is configured, this plugin creates a temporary output directory for TypeScript emit to avoid TS5055 (overwriting input files). The effective `outDir` (whether user-provided or the temporary one) is excluded from plugin processing to prevent re-processing emitted files. The temporary directory is removed after a non-watch build completes; in watch mode it is removed when the watcher stops.
83+
>
84+
> When `allowJs` expands the default include and you have not specified patterns via `include`/`exclude`, the plugin also excludes `**/node_modules/**` by default to avoid transforming third-party code.
8185
8286
### `filterRoot`
8387

packages/typescript/src/index.ts

Lines changed: 101 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import * as path from 'path';
2+
import * as fs from 'fs';
3+
import * as os from 'os';
24

35
import { createFilter } from '@rollup/pluginutils';
46

@@ -40,10 +42,101 @@ export default function typescript(options: RollupTypescriptOptions = {}): Plugi
4042
const tsCache = new TSCache(cacheDir);
4143
const emittedFiles = new Map<string, string>();
4244
const watchProgramHelper = new WatchProgramHelper();
45+
let autoOutDir: string | null = null;
46+
// Centralize temp outDir cleanup to avoid duplication/drift across hooks
47+
const cleanupAutoOutDir = () => {
48+
if (!autoOutDir) return;
49+
try {
50+
fs.rmSync(autoOutDir, { recursive: true, force: true });
51+
} catch {
52+
// ignore cleanup failures
53+
}
54+
autoOutDir = null;
55+
};
56+
// Ensure the TypeScript watch program is closed and temp outDir is cleaned
57+
// even if closing throws. Call this from lifecycle hooks that need teardown.
58+
const closeProgramAndCleanup = () => {
59+
try {
60+
// ESLint doesn't understand optional chaining
61+
// eslint-disable-next-line
62+
program?.close();
63+
} finally {
64+
cleanupAutoOutDir();
65+
}
66+
};
4367

4468
const parsedOptions = parseTypescriptConfig(ts, tsconfig, compilerOptions, noForceEmit);
45-
const filter = createFilter(include || '{,**/}*.(cts|mts|ts|tsx)', exclude, {
46-
resolve: filterRoot ?? parsedOptions.options.rootDir
69+
70+
// When processing JS via allowJs, redirect emit output away from source files
71+
// to avoid TS5055 (cannot write file because it would overwrite input file).
72+
// We only set a temp outDir if the user did not configure one.
73+
if (parsedOptions.options.allowJs && !parsedOptions.options.outDir) {
74+
// Create a unique temporary outDir to avoid TS5055 when emitting JS
75+
autoOutDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rollup-plugin-typescript-allowjs-'));
76+
parsedOptions.options.outDir = autoOutDir;
77+
}
78+
79+
// Determine default include pattern. By default we only process TS files.
80+
// When the consumer enables `allowJs` in their tsconfig/compiler options,
81+
// also include common JS extensions so modern JS syntax in .js files is
82+
// downleveled by TypeScript as expected.
83+
const defaultInclude = parsedOptions.options.allowJs
84+
? '{,**/}*.{cts,mts,ts,tsx,js,jsx,mjs,cjs}'
85+
: '{,**/}*.{cts,mts,ts,tsx}';
86+
87+
// Build filter exclusions, ensuring we never re-process TypeScript emit outputs.
88+
// Always exclude the effective outDir (user-provided or the auto-created temp dir).
89+
const filterExclude = Array.isArray(exclude) ? [...exclude] : exclude ? [exclude] : [];
90+
// When auto-expanding to include JS (allowJs) and the user did not provide
91+
// custom include/exclude patterns, avoid transforming third-party code by
92+
// default by excluding node_modules.
93+
if (parsedOptions.options.allowJs && !include && !exclude) {
94+
filterExclude.push('**/node_modules/**');
95+
}
96+
const effectiveOutDir = parsedOptions.options.outDir
97+
? path.resolve(parsedOptions.options.outDir)
98+
: null;
99+
// Determine the base used for containment checks. If pattern resolution is disabled
100+
// (filterRoot === false), fall back to process.cwd() so we don't accidentally
101+
// exclude sources when e.g. outDir='.'.
102+
const willResolvePatterns = filterRoot !== false;
103+
// Only treat string values of `filterRoot` as a base directory; booleans (e.g., true)
104+
// should not flow into path resolution. Fallback to the tsconfig `rootDir` when not set.
105+
const configuredBase = willResolvePatterns
106+
? typeof filterRoot === 'string'
107+
? filterRoot
108+
: parsedOptions.options.rootDir
109+
: null;
110+
const filterBaseAbs = configuredBase ? path.resolve(configuredBase) : null;
111+
if (effectiveOutDir) {
112+
// Avoid excluding sources: skip when the filter base (or cwd fallback) lives inside outDir.
113+
// Use path.relative with root equality guard for cross-platform correctness.
114+
const baseForContainment = filterBaseAbs ?? process.cwd();
115+
const outDirContainsFilterBase = (() => {
116+
// Different roots (e.g., drive letters on Windows) cannot be in a parent/child relationship.
117+
// Normalize Windows drive-letter case before comparison to avoid false mismatches.
118+
const getRoot = (p: string) => {
119+
const r = path.parse(p).root;
120+
return process.platform === 'win32' ? r.toLowerCase() : r;
121+
};
122+
if (getRoot(effectiveOutDir) !== getRoot(baseForContainment)) return false;
123+
const rel = path.relative(effectiveOutDir, baseForContainment);
124+
// rel === '' -> same dir; absolute or '..' => outside
125+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
126+
})();
127+
if (!outDirContainsFilterBase) {
128+
filterExclude.push(normalizePath(path.join(effectiveOutDir, '**')));
129+
}
130+
}
131+
const filter = createFilter(include || defaultInclude, filterExclude, {
132+
// Guard against non-string truthy values (e.g., boolean true). Only strings are valid
133+
// for `resolve`; `false` disables resolution. Otherwise, fall back to `rootDir`.
134+
resolve:
135+
typeof filterRoot === 'string'
136+
? filterRoot
137+
: filterRoot === false
138+
? false
139+
: parsedOptions.options.rootDir || process.cwd()
47140
});
48141
parsedOptions.fileNames = parsedOptions.fileNames.filter(filter);
49142

@@ -100,12 +193,15 @@ export default function typescript(options: RollupTypescriptOptions = {}): Plugi
100193

101194
buildEnd() {
102195
if (this.meta.watchMode !== true) {
103-
// ESLint doesn't understand optional chaining
104-
// eslint-disable-next-line
105-
program?.close();
196+
closeProgramAndCleanup();
106197
}
107198
},
108199

200+
// Ensure program is closed and temp outDir is removed exactly once when watch stops
201+
closeWatcher() {
202+
closeProgramAndCleanup();
203+
},
204+
109205
renderStart(outputOptions) {
110206
validateSourceMap(this, parsedOptions.options, outputOptions, parsedOptions.autoSetSourceMap);
111207
validatePaths(this, parsedOptions.options, outputOptions);
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default function run() {
2+
const obj = { prop: { nested: 1 } };
3+
const a = obj.prop?.nested;
4+
const b = {};
5+
b.timeout ??= 123;
6+
return [a, b.timeout];
7+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"compilerOptions": {
3+
"allowJs": true,
4+
"checkJs": false,
5+
"target": "ES2018",
6+
"module": "ESNext",
7+
"lib": ["es6"]
8+
},
9+
"include": ["src/**/*"]
10+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import util from './util.js';
2+
3+
export default function main() {
4+
return util();
5+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default function util() {
2+
const obj = { prop: { nested: 7 } };
3+
const a = obj.prop?.nested;
4+
const c = {};
5+
c.timeout ??= 9;
6+
return [a, c.timeout];
7+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"compilerOptions": {
3+
"allowJs": true,
4+
"checkJs": false,
5+
"target": "ES2018",
6+
"module": "ESNext"
7+
},
8+
"include": ["src/**/*"]
9+
}

packages/typescript/test/fixtures/incremental-single/tsconfig.tsbuildinfo

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

packages/typescript/test/test.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1629,3 +1629,68 @@ test.serial('observes included declarations', async (t) => {
16291629
const files = await getCode(bundle, { format: 'es' }, true);
16301630
t.is(files.length, 1);
16311631
});
1632+
1633+
test.serial('downlevels JS when allowJs is true (default include)', async (t) => {
1634+
const bundle = await rollup({
1635+
input: 'fixtures/allow-js-downlevel/src/main.js',
1636+
plugins: [typescript({ tsconfig: 'fixtures/allow-js-downlevel/tsconfig.json' })],
1637+
onwarn
1638+
});
1639+
const code = await getCode(bundle, { format: 'es' });
1640+
1641+
// Optional chaining and nullish coalescing assignment should be transformed
1642+
t.false(code.includes('?.'), code);
1643+
t.false(code.includes('??='), code);
1644+
1645+
const result = await evaluateBundle(bundle);
1646+
t.deepEqual(result(), [1, 123]);
1647+
});
1648+
1649+
test.serial('downlevels JS imported by TS when allowJs is true', async (t) => {
1650+
const bundle = await rollup({
1651+
input: 'fixtures/allow-js-from-ts/src/main.ts',
1652+
plugins: [typescript({ tsconfig: 'fixtures/allow-js-from-ts/tsconfig.json' })],
1653+
onwarn
1654+
});
1655+
const code = await getCode(bundle, { format: 'es' });
1656+
1657+
t.false(code.includes('?.'), code);
1658+
t.false(code.includes('??='), code);
1659+
1660+
const result = await evaluateBundle(bundle);
1661+
t.deepEqual(result(), [7, 9]);
1662+
});
1663+
1664+
test.serial('excludes user-configured outDir from processing when allowJs is true', async (t) => {
1665+
const outDir = path.join(__dirname, 'fixtures/allow-js-from-ts/.out');
1666+
fs.rmSync(outDir, { recursive: true, force: true });
1667+
1668+
const bundle = await rollup({
1669+
input: 'fixtures/allow-js-from-ts/src/main.ts',
1670+
plugins: [
1671+
typescript({
1672+
tsconfig: 'fixtures/allow-js-from-ts/tsconfig.json',
1673+
compilerOptions: { outDir }
1674+
})
1675+
],
1676+
onwarn
1677+
});
1678+
1679+
try {
1680+
// Ensure Rollup did not pull any files from the outDir into the graph
1681+
const normalizeForCompare = (p) => {
1682+
const r = path.resolve(p);
1683+
return process.platform === 'win32' ? r.toLowerCase() : r;
1684+
};
1685+
const outDirAbs = normalizeForCompare(outDir);
1686+
const isInside = (parent, child) => {
1687+
const rel = path.relative(parent, normalizeForCompare(child));
1688+
// same dir or within parent
1689+
return !rel.startsWith('..') && !path.isAbsolute(rel);
1690+
};
1691+
t.true(bundle.watchFiles.every((f) => !isInside(outDirAbs, f)));
1692+
} finally {
1693+
await bundle.close();
1694+
fs.rmSync(outDir, { recursive: true, force: true });
1695+
}
1696+
});

0 commit comments

Comments
 (0)