Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
21 changes: 16 additions & 5 deletions packages/typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import * as os from 'os';
import { createFilter } from '@rollup/pluginutils';

import type { Plugin, SourceDescription } from 'rollup';
import type { Watch } from 'typescript';
import type { Diagnostic, Watch } from 'typescript';

import type { RollupTypescriptOptions } from '../types';

import createFormattingHost from './diagnostics/host';
import { emitDiagnostics } from './diagnostics/emit';
import createModuleResolver from './moduleResolution';
import { getPluginOptions } from './options/plugin';
import { emitParsedOptionsErrors, parseTypescriptConfig } from './options/tsconfig';
Expand Down Expand Up @@ -42,6 +43,8 @@ export default function typescript(options: RollupTypescriptOptions = {}): Plugi
} = getPluginOptions(options);
const tsCache = new TSCache(cacheDir);
const emittedFiles = new Map<string, string>();
const diagnostics: Diagnostic[] = [];
const watchFiles = new Set<string>();
const watchProgramHelper = new WatchProgramHelper();
let autoOutDir: string | null = null;
// Centralize temp outDir cleanup to avoid duplication/drift across hooks
Expand Down Expand Up @@ -149,7 +152,7 @@ export default function typescript(options: RollupTypescriptOptions = {}): Plugi
return {
name: 'typescript',

buildStart(rollupOptions) {
async buildStart(rollupOptions) {
emitParsedOptionsErrors(ts, this, parsedOptions);

preflight({
Expand All @@ -169,6 +172,8 @@ export default function typescript(options: RollupTypescriptOptions = {}): Plugi
}
if (!program) {
program = createWatchProgram(ts, this, {
diagnostics,
watchFiles,
formatHost,
resolveModule,
parsedOptions,
Expand All @@ -185,10 +190,17 @@ export default function typescript(options: RollupTypescriptOptions = {}): Plugi
recreateTransformersOnRebuild
});
}

await watchProgramHelper.wait();
// An error can abort before Rollup loads imported or type-only dependencies.
if (this.meta.watchMode && parsedOptions.options.noEmitOnError) {
watchFiles.forEach((fileName) => this.addWatchFile(fileName));
}
emitDiagnostics(ts, this, formatHost, diagnostics);
},

watchChange(id) {
if (!filter(id)) return;
if (!filter(id) && !watchFiles.has(normalizePath(id))) return;

watchProgramHelper.watch();
},
Expand Down Expand Up @@ -244,11 +256,10 @@ export default function typescript(options: RollupTypescriptOptions = {}): Plugi
return null;
},

async load(id) {
load(id) {
if (!filter(id)) return null;

this.addWatchFile(id);
await watchProgramHelper.wait();

const fileName = normalizePath(id);
if (!parsedOptions.fileNames.includes(fileName)) {
Expand Down
21 changes: 20 additions & 1 deletion packages/typescript/src/watchProgram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { buildDiagnosticReporter } from './diagnostics/emit';
import type { DiagnosticsHost } from './diagnostics/host';
import type { Resolver } from './moduleResolution';
import { mergeTransformers } from './customTransformers';
import { normalizePath } from './outputFile';

const { DiagnosticCategory } = typescript;
type BuilderProgram = EmitAndSemanticDiagnosticsBuilderProgram;
Expand All @@ -30,6 +31,10 @@ enum DiagnosticCode {
}

interface CreateProgramOptions {
/** Diagnostics from the current compilation, reported from Rollup hooks. */
diagnostics: Diagnostic[];
/** Compiler inputs to watch when an error prevents Rollup from loading them. */
watchFiles: Set<string>;
/** Formatting host used to get some system functions and emit type errors. */
formatHost: DiagnosticsHost;
/** Parsed Typescript compiler options. */
Expand Down Expand Up @@ -143,6 +148,8 @@ function createWatchHost(
ts: typeof typescript,
context: PluginContext,
{
diagnostics,
watchFiles,
formatHost,
parsedOptions,
writeFile,
Expand All @@ -159,7 +166,9 @@ function createWatchHost(
parsedOptions.options,
ts.sys,
createProgram,
buildDiagnosticReporter(ts, context, formatHost),
parsedOptions.options.noEmitOnError
? (diagnostic) => diagnostics.push(diagnostic)
: buildDiagnosticReporter(ts, context, formatHost),
status,
parsedOptions.projectReferences
);
Expand All @@ -169,6 +178,16 @@ function createWatchHost(
...baseHost,
/** Override the created program so an in-memory emit is used */
afterProgramCreate(program) {
diagnostics.splice(0);
if (parsedOptions.options.noEmitOnError) {
const currentProgram = program.getProgram();
watchFiles.clear();
currentProgram.getSourceFiles().forEach((file) => {
if (!currentProgram.isSourceFileDefaultLibrary(file)) {
watchFiles.add(normalizePath(file.fileName));
}
});
}
// Optionally recompute custom transformers for each new builder program in watch mode
// so factories capture the current Program/TypeChecker and any provided getters can
// return the latest values. When disabled (default), legacy behavior reuses the
Expand Down
183 changes: 183 additions & 0 deletions packages/typescript/test/watch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/* eslint-disable no-await-in-loop -- Each watch edit depends on the previous build. */
const fs = require('fs');
const path = require('path');

const { watch } = require('rollup');

const typescript = require('..');

function waitForBuild(watcher, buildNumber) {
return new Promise((resolve, reject) => {
let result;
const timer = setTimeout(() => {
watcher.off('event', handleEvent);
reject(new Error(`Timed out waiting for watch build ${buildNumber}`));
}, 10000);
function handleEvent(event) {
if (event.code === 'ERROR' || event.code === 'BUNDLE_END') result = event;
if (event.code === 'END') {
clearTimeout(timer);
watcher.off('event', handleEvent);
resolve(result);
}
}
watcher.on('event', handleEvent);
});
}

for (const { startsWithError, dependency } of [
{ startsWithError: true, dependency: 'root' },
{ startsWithError: false, dependency: 'root' },
{ startsWithError: true, dependency: 'imported' },
{ startsWithError: true, dependency: 'excluded type-only' }
]) {
test.sequential(
`recovers from consecutive type errors in watch mode (${
startsWithError ? 'invalid' : 'valid'
} initial build, ${dependency} file)`,
async () => {
const outputDir = path.join(__dirname, 'output');
fs.mkdirSync(outputDir, { recursive: true });
const dir = fs.mkdtempSync(path.join(outputDir, 'watch-no-emit-'));
const input = path.join(dir, 'main.ts');
const edited = dependency === 'root' ? input : path.join(dir, 'dep.ts');
const typeOnly = dependency === 'excluded type-only';
const output = path.join(dir, 'bundle.js');
const tsconfig = path.join(dir, 'tsconfig.json');
const builds = [
...(startsWithError ? [] : [{ source: 'export const value: number = 1;', valid: true }]),
{
source: typeOnly
? 'export type Value = string;'
: "export const value: number = 'invalid-one';",
valid: false
},
{
source: typeOnly
? 'export type Value = boolean;'
: "export const value: number = 'invalid-two';",
valid: false
},
{
source: typeOnly ? 'export type Value = number;' : 'export const value: number = 42;',
valid: true
}
];
if (typeOnly) {
fs.writeFileSync(
input,
"import type { Value } from './dep'; export const value: Value = 42;"
);
} else if (dependency === 'imported') {
fs.writeFileSync(input, "export { value } from './dep';");
}
fs.writeFileSync(edited, builds[0].source);
fs.writeFileSync(
tsconfig,
JSON.stringify({
compilerOptions: {
module: 'esnext',
target: 'es2015',
noEmitOnError: true,
types: [],
skipLibCheck: true
},
files: ['main.ts']
})
);
const watcher = watch({
input,
output: { file: output, format: 'es' },
plugins: [typescript({ tsconfig, ...(typeOnly ? { exclude: [edited] } : {}) })]
});
let previousOutput;
let previousMtime;
try {
let nextBuild = waitForBuild(watcher, 0);
for (let i = 0; i < builds.length; i += 1) {
const build = builds[i];
const event = await nextBuild;
expect(event.code).toBe(build.valid ? 'BUNDLE_END' : 'ERROR');
if (build.valid) {
await event.result.close();
const code = fs.readFileSync(output, 'utf8');
expect(code).toContain(i === builds.length - 1 ? 'value = 42' : 'value = 1');
previousOutput = code;
previousMtime = fs.statSync(output).mtimeMs;
} else {
expect(event.error.plugin).toBe('typescript');
expect(event.error.pluginCode).toBe('TS2322');
if (previousOutput) {
expect(fs.readFileSync(output, 'utf8')).toBe(previousOutput);
expect(fs.statSync(output).mtimeMs).toBe(previousMtime);
} else {
expect(fs.existsSync(output)).toBe(false);
}
}
if (i + 1 < builds.length) {
nextBuild = waitForBuild(watcher, i + 1);
await fs.promises.writeFile(edited, builds[i + 1].source);
}
}
} finally {
await watcher.close();
fs.rmSync(dir, { recursive: true, force: true });
}
},
30000
);
}

test.sequential(
'reports type-only watch warnings when noEmitOnError is false',
async () => {
const outputDir = path.join(__dirname, 'output');
fs.mkdirSync(outputDir, { recursive: true });
const dir = fs.mkdtempSync(path.join(outputDir, 'watch-warnings-'));
const input = path.join(dir, 'main.ts');
const dependency = path.join(dir, 'dep.ts');
const output = path.join(dir, 'bundle.js');
const tsconfig = path.join(dir, 'tsconfig.json');
fs.writeFileSync(input, "import type { Value } from './dep'; export const value: Value = 42;");
fs.writeFileSync(dependency, 'export type Value = number;');
fs.writeFileSync(
tsconfig,
JSON.stringify({
compilerOptions: { module: 'esnext', target: 'es2015', noEmitOnError: false, types: [] },
files: ['main.ts']
})
);
let receiveWarning;
const watcher = watch({
input,
output: { file: output, format: 'es' },
plugins: [typescript({ tsconfig, exclude: [dependency] })],
onwarn(warning) {
if (receiveWarning) receiveWarning(warning);
}
});
try {
const initial = await waitForBuild(watcher, 0);
expect(initial.code).toBe('BUNDLE_END');
await initial.result.close();
const nextWarning = new Promise((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error('Timed out waiting for a type warning')),
10000
);
receiveWarning = (warning) => {
clearTimeout(timer);
resolve(warning);
};
});
await fs.promises.writeFile(dependency, 'export type Value = string;');
const warning = await nextWarning;
expect(warning.plugin).toBe('typescript');
expect(warning.pluginCode).toBe('TS2322');
} finally {
await watcher.close();
fs.rmSync(dir, { recursive: true, force: true });
}
},
30000
);
Loading