Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
41d677e
feat(typescript): honor allowJs and transform .js files\n\n- Expand d…
CharlieHelps Oct 20, 2025
7e8812d
feat(typescript): auto-include JS when allowJs is set; use temp outDi…
CharlieHelps Oct 20, 2025
2fa02de
fix(typescript): exclude effective outDir from filter to avoid re-pro…
CharlieHelps Oct 20, 2025
ea9aedb
fix(typescript): use path.relative for robust outDir containment check
CharlieHelps Oct 20, 2025
777d3bc
test(typescript): add regression ensuring user-configured outDir is e…
CharlieHelps Oct 20, 2025
f110caa
fix(typescript): gate temp outDir cleanup to non-watch; cleanup in cl…
CharlieHelps Oct 20, 2025
fe8e253
test(typescript): harden user outDir exclusion test (robust path chec…
CharlieHelps Oct 20, 2025
96aa8db
fix(typescript): robust outDir containment; gate temp outDir cleanup …
CharlieHelps Oct 20, 2025
0e1303f
test(typescript): make isInside helper treat equal path as inside; mi…
CharlieHelps Oct 20, 2025
a906b37
test(typescript): remove REBUILD_WITH_WATCH_OFF from incremental-watc…
CharlieHelps Oct 20, 2025
3734281
fix(typescript): guard string-only filterRoot base; close watch progr…
CharlieHelps Oct 22, 2025
3832864
fix(typescript): normalize Windows drive roots in containment; exclud…
CharlieHelps Oct 22, 2025
f52946a
docs(typescript): note default node_modules exclusion when allowJs ex…
CharlieHelps Oct 22, 2025
e1fa4f0
fix(typescript): default filter resolve to cwd when rootDir unset\n\n…
CharlieHelps Oct 22, 2025
9012668
refactor(typescript): centralize temp outDir cleanup into a helper
CharlieHelps Oct 22, 2025
933f6cd
refactor(typescript): unify program close + temp outDir cleanup via h…
CharlieHelps Oct 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ A [picomatch pattern](https://github.com/micromatch/picomatch), or array of patt
Type: `String` | `Array[...String]`<br>
Default: `null`

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.
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.

> 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.

### `filterRoot`

Expand Down
87 changes: 85 additions & 2 deletions packages/typescript/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import * as path from 'path';
import * as fs from 'fs';
import * as os from 'os';

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

Expand Down Expand Up @@ -40,10 +42,69 @@ export default function typescript(options: RollupTypescriptOptions = {}): Plugi
const tsCache = new TSCache(cacheDir);
const emittedFiles = new Map<string, string>();
const watchProgramHelper = new WatchProgramHelper();
let autoOutDir: string | null = null;

const parsedOptions = parseTypescriptConfig(ts, tsconfig, compilerOptions, noForceEmit);
const filter = createFilter(include || '{,**/}*.(cts|mts|ts|tsx)', exclude, {
resolve: filterRoot ?? parsedOptions.options.rootDir

// When processing JS via allowJs, redirect emit output away from source files
// to avoid TS5055 (cannot write file because it would overwrite input file).
// We only set a temp outDir if the user did not configure one.
if (parsedOptions.options.allowJs && !parsedOptions.options.outDir) {
// Create a unique temporary outDir to avoid TS5055 when emitting JS
autoOutDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rollup-plugin-typescript-allowjs-'));
parsedOptions.options.outDir = autoOutDir;
}

// Determine default include pattern. By default we only process TS files.
// When the consumer enables `allowJs` in their tsconfig/compiler options,
// also include common JS extensions so modern JS syntax in .js files is
// downleveled by TypeScript as expected.
const defaultInclude = parsedOptions.options.allowJs
? '{,**/}*.{cts,mts,ts,tsx,js,jsx,mjs,cjs}'
: '{,**/}*.{cts,mts,ts,tsx}';

// Build filter exclusions, ensuring we never re-process TypeScript emit outputs.
// Always exclude the effective outDir (user-provided or the auto-created temp dir).
const filterExclude = Array.isArray(exclude) ? [...exclude] : exclude ? [exclude] : [];
const effectiveOutDir = parsedOptions.options.outDir
? path.resolve(parsedOptions.options.outDir)
: null;
// Determine the base used for containment checks. If pattern resolution is disabled
// (filterRoot === false), fall back to process.cwd() so we don't accidentally
// exclude sources when e.g. outDir='.'.
const willResolvePatterns = filterRoot !== false;
// Only treat string values of `filterRoot` as a base directory; booleans (e.g., true)
// should not flow into path resolution. Fallback to the tsconfig `rootDir` when not set.
const configuredBase = willResolvePatterns
? typeof filterRoot === 'string'
? filterRoot
: parsedOptions.options.rootDir
: null;
const filterBaseAbs = configuredBase ? path.resolve(configuredBase) : null;
if (effectiveOutDir) {
// Avoid excluding sources: skip when the filter base (or cwd fallback) lives inside outDir.
// Use path.relative with root equality guard for cross-platform correctness.
const baseForContainment = filterBaseAbs ?? process.cwd();
const outDirContainsFilterBase = (() => {
// Different roots (e.g., drive letters on Windows) cannot be in a parent/child relationship
if (path.parse(effectiveOutDir).root !== path.parse(baseForContainment).root) return false;
const rel = path.relative(effectiveOutDir, baseForContainment);
// rel === '' -> same dir; absolute or '..' => outside
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
})();
if (!outDirContainsFilterBase) {
filterExclude.push(normalizePath(path.join(effectiveOutDir, '**')));
}
}
const filter = createFilter(include || defaultInclude, filterExclude, {
// Guard against non-string truthy values (e.g., boolean true). Only strings are valid
// for `resolve`; `false` disables resolution. Otherwise, fall back to `rootDir`.
resolve:
typeof filterRoot === 'string'
? filterRoot
: filterRoot === false
? false
: parsedOptions.options.rootDir
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The resolve option passed to createFilter may end up undefined if neither filterRoot (string) nor parsedOptions.options.rootDir are set. The README documents a default of rootDir ?? tsconfig rootDir ?? process.cwd(). To align runtime behavior with docs and avoid implicit defaults, fall back to process.cwd() explicitly.

Suggestion

Explicitly fall back to process.cwd() when neither filterRoot (string) nor rootDir are available:

resolve:
  typeof filterRoot === 'string'
    ? filterRoot
    : filterRoot === false
    ? false
    : parsedOptions.options.rootDir || process.cwd()

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this change

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@CharlieHelps is this comment still relevant/needed?

Copy link
Contributor Author

@charliecreates charliecreates bot Oct 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expand this to see my work.
  • Confirmed in PR #1920 that the inline comment in packages/typescript/src/index.ts is obsolete since defaultInclude already matches *.{js,jsx,mjs,cjs} with allowJs, autoOutDir prevents TS5055, and createFilter(..., { resolve }) respects filterRoot.
  • Updated packages/typescript/src/index.ts to set resolve in createFilter(include || defaultInclude, filterExclude, { ... }) to parsedOptions.options.rootDir || process.cwd() to handle configs without rootDir.
  • Built @rollup/plugin-typescript successfully; artifacts created at ./dist/cjs/index.js and ./dist/es/index.js.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes—still relevant. I’ve added an explicit fallback to process.cwd() when filterRoot isn’t a string (and not false) and rootDir is unset, so createFilter({ resolve }) never receives undefined. This aligns runtime with the README’s documented default. Change in e1fa4f0.

Verification
# Build (scoped)
$ pnpm --filter @rollup/plugin-typescript build
# Result: success

# Tests (AVA, serial)
$ pnpm --filter @rollup/plugin-typescript test -- --verbose --serial
# Result: 87 tests passed, 2 skipped

# TypeScript
$ pnpm --filter @rollup/plugin-typescript run test:ts
# Result: No errors

# ESLint (scoped)
$ pnpm exec eslint --cache packages/typescript --ext .js,.ts,.mjs
# Result: 0 problems

});
parsedOptions.fileNames = parsedOptions.fileNames.filter(filter);

Expand Down Expand Up @@ -103,6 +164,28 @@ export default function typescript(options: RollupTypescriptOptions = {}): Plugi
// ESLint doesn't understand optional chaining
// eslint-disable-next-line
program?.close();
if (autoOutDir) {
try {
fs.rmSync(autoOutDir, { recursive: true, force: true });
} catch {
// ignore cleanup failures
}
autoOutDir = null;
}
}
},

// Ensure program is closed and temp outDir is removed exactly once when watch stops
closeWatcher() {
// eslint-disable-next-line
program?.close();
if (autoOutDir) {
try {
fs.rmSync(autoOutDir, { recursive: true, force: true });
} catch {
// ignore cleanup failures
}
autoOutDir = null;
}
},

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function run() {
const obj = { prop: { nested: 1 } };
const a = obj.prop?.nested;
const b = {};
b.timeout ??= 123;
return [a, b.timeout];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"allowJs": true,
"checkJs": false,
"target": "ES2018",
"module": "ESNext",
"lib": ["es6"]
},
"include": ["src/**/*"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import util from './util.js';

export default function main() {
return util();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function util() {
const obj = { prop: { nested: 7 } };
const a = obj.prop?.nested;
const c = {};
c.timeout ??= 9;
return [a, c.timeout];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"allowJs": true,
"checkJs": false,
"target": "ES2018",
"module": "ESNext"
},
"include": ["src/**/*"]
}

Large diffs are not rendered by default.

65 changes: 65 additions & 0 deletions packages/typescript/test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1629,3 +1629,68 @@ test.serial('observes included declarations', async (t) => {
const files = await getCode(bundle, { format: 'es' }, true);
t.is(files.length, 1);
});

test.serial('downlevels JS when allowJs is true (default include)', async (t) => {
const bundle = await rollup({
input: 'fixtures/allow-js-downlevel/src/main.js',
plugins: [typescript({ tsconfig: 'fixtures/allow-js-downlevel/tsconfig.json' })],
onwarn
});
const code = await getCode(bundle, { format: 'es' });

// Optional chaining and nullish coalescing assignment should be transformed
t.false(code.includes('?.'), code);
t.false(code.includes('??='), code);

const result = await evaluateBundle(bundle);
t.deepEqual(result(), [1, 123]);
});

test.serial('downlevels JS imported by TS when allowJs is true', async (t) => {
const bundle = await rollup({
input: 'fixtures/allow-js-from-ts/src/main.ts',
plugins: [typescript({ tsconfig: 'fixtures/allow-js-from-ts/tsconfig.json' })],
onwarn
});
const code = await getCode(bundle, { format: 'es' });

t.false(code.includes('?.'), code);
t.false(code.includes('??='), code);

const result = await evaluateBundle(bundle);
t.deepEqual(result(), [7, 9]);
});

test.serial('excludes user-configured outDir from processing when allowJs is true', async (t) => {
const outDir = path.join(__dirname, 'fixtures/allow-js-from-ts/.out');
fs.rmSync(outDir, { recursive: true, force: true });

const bundle = await rollup({
input: 'fixtures/allow-js-from-ts/src/main.ts',
plugins: [
typescript({
tsconfig: 'fixtures/allow-js-from-ts/tsconfig.json',
compilerOptions: { outDir }
})
],
onwarn
});

try {
// Ensure Rollup did not pull any files from the outDir into the graph
const normalizeForCompare = (p) => {
const r = path.resolve(p);
return process.platform === 'win32' ? r.toLowerCase() : r;
};
const outDirAbs = normalizeForCompare(outDir);
const isInside = (parent, child) => {
const rel = path.relative(parent, normalizeForCompare(child));
// same dir or within parent
return !rel.startsWith('..') && !path.isAbsolute(rel);
};
t.true(bundle.watchFiles.every((f) => !isInside(outDirAbs, f)));
} finally {
await bundle.close();
fs.rmSync(outDir, { recursive: true, force: true });
}
});
Loading