Skip to content

Commit bd736a8

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 bd736a8

6 files changed

Lines changed: 146 additions & 6 deletions

File tree

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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { foo } from './test';
2+
export { testNested } from './nested/test-nested';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const testNested = 'nested';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const foo = 'bar';
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"compilerOptions": {
3+
"moduleResolution": "node",
4+
"emitDecoratorMetadata": true,
5+
"experimentalDecorators": true,
6+
"importHelpers": true,
7+
"esModuleInterop": true,
8+
"skipLibCheck": true,
9+
"skipDefaultLibCheck": true,
10+
"strict": true,
11+
"baseUrl": ".",
12+
"module": "esnext",
13+
"outDir": "../dist/project-1",
14+
"types": [],
15+
"forceConsistentCasingInFileNames": true,
16+
"noImplicitReturns": true,
17+
"noFallthroughCasesInSwitch": true,
18+
"noImplicitOverride": true,
19+
"declaration": true,
20+
"inlineSources": true,
21+
"rootDir": "./",
22+
"allowJs": false,
23+
"composite": false,
24+
"declarationDir": "../dist/project-1",
25+
"noEmitOnError": true,
26+
"sourceMap": true
27+
},
28+
"include": ["**/*.ts"]
29+
}

packages/typescript/test/test.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,47 @@ 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.only('should correctly resolve sourcemap sources when outDir is outside source directory', async (t) => {
714+
// This test verifies the fix for issue #1966
715+
// When TypeScript's outDir places emitted files in a different directory tree than
716+
// the source files (e.g., outDir: "../dist"), TypeScript emits sourcemaps with
717+
// sources relative to the output map file location. Rollup's getCollapsedSourcemap
718+
// resolves those paths relative to the original source file's directory instead.
719+
// The fix rebases the sourcemap sources to be relative to the source file directory.
720+
const bundle = await rollup({
721+
input: './fixtures/outdir-outside-source/my-project-1/src/index.ts',
722+
output: {
723+
dir: './fixtures/outdir-outside-source/dist/project-1',
724+
format: 'es',
725+
entryFileNames: '[name].js',
726+
chunkFileNames: '[name]-[hash].js',
727+
sourcemap: true
728+
},
729+
plugins: [
730+
typescript({
731+
tsconfig: './fixtures/outdir-outside-source/my-project-1/tsconfig.json'
732+
})
733+
],
734+
onwarn
735+
});
736+
const [indexJsOutput] = await getCode(bundle, { format: 'es', sourcemap: true }, true);
737+
738+
const sourcemap = indexJsOutput.map;
739+
740+
// Verify sourcemap has the correct sources
741+
t.is(sourcemap.sources.length, 2, 'Should have exactly 2 sources');
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 [testSourcePath, nestedSourcePath] = sourcemap.sources;
746+
747+
t.is(testSourcePath, 'fixtures/outdir-outside-source/my-project-1/src/test.ts');
748+
t.is(nestedSourcePath, 'fixtures/outdir-outside-source/my-project-1/src/nested/test-nested.ts');
749+
750+
// Verify sourceRoot has been cleared (no longer needed after path rebasing)
751+
t.is(sourcemap.sourceRoot, undefined, 'sourceRoot should be cleared after path rebasing');
752+
});
753+
713754
test.serial('should not fail if source maps are off', async (t) => {
714755
await t.notThrowsAsync(
715756
rollup({

0 commit comments

Comments
 (0)