Skip to content

Commit 31d1e68

Browse files
committed
fix(typescript): rebase sourcemap sources paths when outDir differs from source directory
TypeScript emits sourcemaps with `sources` paths relative to the output map file location (inside `outDir`). `findTypescriptOutput` returned this sourcemap verbatim from the `load` hook, but Rollup's `getCollapsedSourcemap` resolves these paths relative to `path.dirname(id)` — the original source file's directory. When these two directories differ (common in monorepos), the resolved absolute paths are wrong, producing broken source references in the final bundle sourcemap. Rebase each `sources` entry in `findTypescriptOutput` by resolving it from the map file's directory and re-relativizing it against the source file's directory. Fixes #1966
1 parent 7d16103 commit 31d1e68

File tree

6 files changed

+201
-6
lines changed

6 files changed

+201
-6
lines changed

packages/typescript/src/outputFile.ts

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { promises as fs } from 'fs';
44

55
import type typescript from 'typescript';
66

7-
import type { OutputOptions, PluginContext, SourceDescription } from 'rollup';
7+
import type { ExistingRawSourceMap, OutputOptions, PluginContext, SourceDescription } from 'rollup';
88
import type { ParsedCommandLine } from 'typescript';
99

1010
import type TSCache from './tscache';
@@ -65,10 +65,17 @@ export function getEmittedFile(
6565
}
6666

6767
/**
68-
* Finds the corresponding emitted Javascript files for a given Typescript file.
69-
* @param id Path to the Typescript file.
70-
* @param emittedFiles Map of file names to source code,
71-
* containing files emitted by the Typescript compiler.
68+
* Finds the corresponding emitted JavaScript files for a given TypeScript file.
69+
*
70+
* Returns the transpiled code, an optional sourcemap (with rebased source paths),
71+
* and a list of declaration file paths.
72+
*
73+
* @param ts The TypeScript module instance.
74+
* @param parsedOptions The parsed TypeScript compiler options (tsconfig).
75+
* @param id Absolute path to the original TypeScript source file.
76+
* @param emittedFiles Map of output file paths to their content, populated by the TypeScript compiler during emission.
77+
* @param tsCache A cache of previously emitted files for incremental builds.
78+
* @returns An object containing the transpiled code, sourcemap with corrected paths, and declaration file paths.
7279
*/
7380
export default function findTypescriptOutput(
7481
ts: typeof typescript,
@@ -86,9 +93,68 @@ export default function findTypescriptOutput(
8693
const codeFile = emittedFileNames.find(isCodeOutputFile);
8794
const mapFile = emittedFileNames.find(isMapOutputFile);
8895

96+
let map: ExistingRawSourceMap | string | undefined = getEmittedFile(
97+
mapFile,
98+
emittedFiles,
99+
tsCache
100+
);
101+
102+
// Rebase sourcemap `sources` paths from the map file's directory to the
103+
// original source file's directory.
104+
//
105+
// Why this is needed:
106+
// TypeScript emits sourcemaps with `sources` relative to the **output**
107+
// map file location (inside `outDir`), optionally prefixed by `sourceRoot`.
108+
// For example, compiling `my-project/src/test.ts` with
109+
// `outDir: "../dist/project"` produces `dist/project/src/test.js.map`
110+
// containing:
111+
//
112+
// { "sources": ["../../../my-project/src/test.ts"], "sourceRoot": "." }
113+
//
114+
// This resolves correctly from the map file's directory:
115+
// resolve("dist/project/src", ".", "../../../my-project/src/test.ts")
116+
// → "my-project/src/test.ts" ✅
117+
//
118+
// However, Rollup's `getCollapsedSourcemap` resolves these paths relative
119+
// to `dirname(id)` — the **original source file's directory** — not the
120+
// output map file's directory. When `outDir` differs from the source tree
121+
// (common in monorepos), this mismatch produces incorrect absolute paths
122+
// that escape the project root.
123+
//
124+
// The fix resolves each source entry to an absolute path via the map file's
125+
// directory (honoring `sourceRoot`), then re-relativizes it against the
126+
// source file's directory so Rollup can consume it correctly.
127+
if (map && mapFile) {
128+
try {
129+
const parsedMap: ExistingRawSourceMap = JSON.parse(map);
130+
131+
if (parsedMap.sources) {
132+
const mapDir = path.dirname(mapFile);
133+
const sourceDir = path.dirname(id);
134+
const sourceRoot = parsedMap.sourceRoot || '.';
135+
136+
parsedMap.sources = parsedMap.sources.map((source) => {
137+
// Resolve to absolute using the map file's directory + sourceRoot
138+
const absolute = path.resolve(mapDir, sourceRoot, source);
139+
// Re-relativize against the original source file's directory
140+
return path.relative(sourceDir, absolute);
141+
});
142+
143+
// sourceRoot has been folded into the rebased paths; remove it so
144+
// Rollup does not double-apply it during sourcemap collapse.
145+
delete parsedMap.sourceRoot;
146+
147+
map = parsedMap;
148+
}
149+
} catch (e) {
150+
// If the map string is not valid JSON (shouldn't happen for TypeScript
151+
// output), fall through and return the original map string unchanged.
152+
}
153+
}
154+
89155
return {
90156
code: getEmittedFile(codeFile, emittedFiles, tsCache),
91-
map: getEmittedFile(mapFile, emittedFiles, tsCache),
157+
map,
92158
declarations: emittedFileNames.filter((name) => name !== codeFile && name !== mapFile)
93159
};
94160
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
const answer = 42;
2+
3+
export default answer;
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"compilerOptions": {
3+
"lib": ["es2020"],
4+
"outDir": "../dist-outside",
5+
"target": "es2020",
6+
"sourceMap": true,
7+
"skipLibCheck": true,
8+
"noEmitOnError": true,
9+
"noErrorTruncation": true,
10+
"module": "esnext",
11+
"moduleResolution": "node",
12+
"strict": true
13+
},
14+
"include": ["src/**/*"]
15+
}
16+
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export const greeting = 'Hello, World!';
2+
3+
export function greet() {
4+
return greeting;
5+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"compilerOptions": {
3+
"lib": ["es2020"],
4+
"outDir": "../output",
5+
"target": "es2020",
6+
"sourceMap": true,
7+
"mapRoot": "./maps",
8+
"skipLibCheck": true,
9+
"noEmitOnError": true,
10+
"module": "esnext",
11+
"moduleResolution": "node",
12+
"strict": true
13+
},
14+
"include": ["src/**/*"]
15+
}
16+

packages/typescript/test/test.js

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,95 @@ test.serial('should not emit sourceContent that references a non-existent file',
710710
t.false(sourcemap.sourcesContent.includes('//# sourceMappingURL=main.js.map'));
711711
});
712712

713+
test.serial(
714+
'should correctly resolve sourcemap sources when outDir is outside source directory',
715+
async (t) => {
716+
// This test verifies the fix for issue #1966
717+
// When TypeScript's outDir places emitted files in a different directory tree than
718+
// the source files (e.g., outDir: "../dist"), TypeScript emits sourcemaps with
719+
// sources relative to the output map file location. Rollup's getCollapsedSourcemap
720+
// resolves those paths relative to the original source file's directory instead.
721+
// The fix rebases the sourcemap sources to be relative to the source file directory.
722+
const bundle = await rollup({
723+
input: 'fixtures/outdir-outside-source/src/main.ts',
724+
output: {
725+
sourcemap: true
726+
},
727+
plugins: [
728+
typescript({
729+
tsconfig: 'fixtures/outdir-outside-source/tsconfig.json'
730+
})
731+
],
732+
onwarn
733+
});
734+
const output = await getCode(bundle, { format: 'es', sourcemap: true }, true);
735+
const sourcemap = output[0].map;
736+
737+
// Debug: log the sourcemap structure
738+
// console.log('sourcemap:', JSON.stringify(sourcemap, null, 2));
739+
740+
// Verify sourcemap has the correct sources
741+
t.is(sourcemap.sources.length, 1, 'Should have exactly one source');
742+
743+
// The source path should be relative to the source file's directory
744+
// and should NOT point multiple levels above the project root
745+
const [sourcePath] = sourcemap.sources;
746+
747+
// Before the fix, this would be something like "../../../outdir-outside-source/src/main.ts"
748+
// After the fix, it should be just "main.ts" (relative to src/ directory)
749+
t.false(
750+
sourcePath.startsWith('../../../'),
751+
`Source path should not go above project root: ${sourcePath}`
752+
);
753+
754+
// The source should correctly point to main.ts
755+
t.true(
756+
sourcePath === 'main.ts' || sourcePath.endsWith('main.ts'),
757+
`Source path should reference main.ts: ${sourcePath}`
758+
);
759+
760+
// Verify sourceRoot has been cleared (no longer needed after path rebasing)
761+
t.is(sourcemap.sourceRoot, undefined, 'sourceRoot should be cleared after path rebasing');
762+
}
763+
);
764+
765+
test.serial('should correctly resolve sourcemap sources with mapRoot option', async (t) => {
766+
// This test verifies that the fix works correctly even when mapRoot is set
767+
const bundle = await rollup({
768+
input: 'fixtures/outdir-with-sourceroot/src/greet.ts',
769+
output: {
770+
sourcemap: true
771+
},
772+
plugins: [
773+
typescript({
774+
tsconfig: 'fixtures/outdir-with-sourceroot/tsconfig.json'
775+
})
776+
],
777+
onwarn
778+
});
779+
const output = await getCode(bundle, { format: 'es', sourcemap: true }, true);
780+
const sourcemap = output[0].map;
781+
782+
// Verify sourcemap has the correct sources
783+
t.is(sourcemap.sources.length, 1, 'Should have exactly one source');
784+
785+
const [sourcePath] = sourcemap.sources;
786+
787+
// The source path should correctly reference greet.ts without going above project root
788+
t.false(
789+
sourcePath.startsWith('../../../'),
790+
`Source path should not go above project root: ${sourcePath}`
791+
);
792+
793+
t.true(
794+
sourcePath === 'greet.ts' || sourcePath.endsWith('greet.ts'),
795+
`Source path should reference greet.ts: ${sourcePath}`
796+
);
797+
798+
// Verify sourceRoot has been cleared
799+
t.is(sourcemap.sourceRoot, undefined, 'sourceRoot should be cleared after path rebasing');
800+
});
801+
713802
test.serial('should not fail if source maps are off', async (t) => {
714803
await t.notThrowsAsync(
715804
rollup({

0 commit comments

Comments
 (0)