Skip to content

fix(vite-plugin-angular): preserve TS sourcemaps in test pipeline#2333

Merged
brandonroberts merged 1 commit into
betafrom
fix/vite-plugin-angular-preserve-sourcemaps
May 9, 2026
Merged

fix(vite-plugin-angular): preserve TS sourcemaps in test pipeline#2333
brandonroberts merged 1 commit into
betafrom
fix/vite-plugin-angular-preserve-sourcemaps

Conversation

@brandonroberts

Copy link
Copy Markdown
Member

PR Checklist

Closes #2332

Likely also addresses #2296 (coverage variant of the same root cause — coverage maps to TS-emitted JS instead of original .ts).

Affected scope

  • Primary scope: vite-plugin-angular
  • Secondary scopes: vitest-angular (integration test fixture)

Recommended merge strategy for maintainer [optional]

  • Squash merge
  • Rebase merge
  • Other

What is the new behavior?

Restores accurate sourcemap line numbers for .ts test files when running Vitest against @analogjs/vite-plugin-angular on Vite 8 / rolldown. Stack frames in Error.stack, breakpoints in the Vitest VS Code extension, and coverage reports now point at the original TypeScript line rather than the post-compile JS line.

Three changes in one fix:

  1. Switch TS emit from inline → separate .map. Configure sourceMap: !isProd, inlineSourceMap: false so the TypeScript compiler emits a sibling .js.map instead of baking a base64 data URI into the .js. The angular transform hook captures the map and returns it via { code, map }, and the stale trailing //# sourceMappingURL= comment is stripped so downstream tools don't see two references.

  2. Fix order-of-arrival bug in writeFileCallback. TS emits the .js.map before the .js. The original handler only stored the map when an entry already existed in outputFiles, so the map was silently dropped on every emit. The handler now upserts in either order.

  3. Skip the post-OXC/esbuild transform when the upstream map exists. angularVitestSourcemapPlugin was a workaround from the esbuild era — esbuild auto-chains inline sourcemaps in its transform API, so post-processing produced a fresh map back to the original .ts for free. OXC does not auto-chain (inMap is documented but ignored in practice), so re-running it over already-compiled JS produces a map relative to the TS-emitted JS instead of the original source, breaking the chain Vite is wiring up. The post-pass now runs only for files Angular's compilation skipped (e.g. non-decorator .ts under useAngularCompilationAPI), which is what it was originally intended for.

Test plan

  • nx run tests-vitest-angular:test — new sub-project under tests/vitest-angular/src/sourcemap/ asserts Error.stack reports a line within 6 of the original throw site (Angular's compile-time ɵfac/ɵcmp insertions account for a small irreducible offset). Pre-fix this offset is ~21 lines; post-fix it's 0–4.
  • Standalone reproduction project (npm create analog@latest) with 5 stacked decorators above the throw: pre-fix the stack reports an 11-line offset; post-fix it matches the source line exactly.
  • Existing aot, providers, reset-test-bed-between-tests, and snapshot-serializers integration projects continue to pass.
  • nx format:check
  • pnpm build
  • pnpm test (subset — tests-vitest-angular)
  • Manual verification

Does this PR introduce a breaking change?

  • Yes
  • No

Other information

The getInMap callback is plumbed end-to-end so that if transformWithOxc's inMap parameter starts honoring chained input maps in a future rolldown release, switching the post-plugin from "skip when map exists" to "chain when map exists" is a single-line change. Filed mentally as a TODO; happy to file an upstream OXC issue separately if useful.

🤖 Generated with Claude Code

@netlify

netlify Bot commented May 9, 2026

Copy link
Copy Markdown

Deploy Preview for analog-blog ready!

Name Link
🔨 Latest commit a3a7556
🔍 Latest deploy log https://app.netlify.com/projects/analog-blog/deploys/69ff16b7d1a56a00088bfbc8
😎 Deploy Preview https://deploy-preview-2333--analog-blog.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented May 9, 2026

Copy link
Copy Markdown

Deploy Preview for analog-docs ready!

Name Link
🔨 Latest commit a3a7556
🔍 Latest deploy log https://app.netlify.com/projects/analog-docs/deploys/69ff16b705ca3d0008033d16
😎 Deploy Preview https://deploy-preview-2333--analog-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented May 9, 2026

Copy link
Copy Markdown

Deploy Preview for analog-app ready!

Name Link
🔨 Latest commit a3a7556
🔍 Latest deploy log https://app.netlify.com/projects/analog-app/deploys/69ff16b77476fa0008e8e529
😎 Deploy Preview https://deploy-preview-2333--analog-app.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented May 9, 2026

Copy link
Copy Markdown

Review Change Stack
No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 40593341-b6cc-439f-a802-402e7d9f4745

📥 Commits

Reviewing files that changed from the base of the PR and between 80d4910 and a3a7556.

📒 Files selected for processing (6)
  • packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts
  • packages/vite-plugin-angular/src/lib/angular-vitest-plugin.ts
  • tests/vitest-angular/src/sourcemap/sourcemap.spec.ts
  • tests/vitest-angular/src/sourcemap/test-setup.ts
  • tests/vitest-angular/src/sourcemap/vitest.project.ts
  • tests/vitest-angular/vitest.config.ts
✅ Files skipped from review due to trivial changes (1)
  • tests/vitest-angular/src/sourcemap/test-setup.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • tests/vitest-angular/vitest.config.ts
  • tests/vitest-angular/src/sourcemap/vitest.project.ts
  • packages/vite-plugin-angular/src/lib/angular-vitest-plugin.ts
  • packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts

📝 Walkthrough

Walkthrough

This PR adds source map preservation to the Angular Vite plugin and vitest integration. The plugin now captures TypeScript-emitted source maps during both the JIT transform and full compilation paths, stores them in outputFiles, and exposes a retrieval function to vitest. The vitest plugin accepts an optional getInMap callback to avoid reprocessing files with cached maps. TypeScript compiler configuration is adjusted to align map emission behavior. A new test suite validates that error stack traces correctly map back to original TypeScript source lines with configurable tolerance for emit precision variance.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title uses Conventional Commit style with a supported package scope (vite-plugin-angular), a clear action verb (fix), and accurately summarizes the main change (preserving TS sourcemaps in the test pipeline).
Description check ✅ Passed The description comprehensively explains the changes across multiple files, relates directly to the changeset, includes issue references, test verification, and technical rationale for each modification.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented May 9, 2026

Copy link
Copy Markdown

This PR touches multiple package scopes: vite-plugin-angular, vitest-angular.

Please confirm the changes are closely related. Squash merge is highly preferred. If you recommend a non-squash merge, add a brief note explaining why the commit boundaries matter and why this PR should bypass focused changes per package.

@github-actions github-actions Bot added scope:vite-plugin-angular Changes in @analogjs/vite-plugin-angular scope:vitest-angular Changes in @analogjs/vitest-angular labels May 9, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts (1)

1257-1314: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Capture the actual emitted map filename from TypeScript's emit callback.

The regex id.replace(/\.ts$/, '.js.map') only matches .ts extensions, but the plugin supports .cts and .mts (per TS_EXT_REGEX and plugin-config tests). For .cts/.mts sources, this produces .cts.js.map/.mts.js.map, which won't match line 1272's /\.[cm]?js\.map$/ pattern and will fail to attach the source map. Use the actual filename TypeScript provides in the emit callback instead—this stays consistent with the emit patterns and handles all supported extensions correctly.

       let content = '';
       let map: string | undefined;
+      let mapFilename: string | undefined;
       builder.emit(
         sourceFile,
         (filename, data) => {
           if (/\.[cm]?js$/.test(filename)) {
             content = data;
           }

           if (/\.[cm]?js\.map$/.test(filename)) {
             map = data;
+            mapFilename = filename;
           }

           if (
             !watchMode &&
             !isTest &&
             /\.d\.ts/.test(filename) &&
             !filename.includes('.ngtypecheck.')
           ) {
             // output to library root instead /src
             const declarationPath = resolve(
               config.root,
               config.build.outDir,
               relative(config.root, filename),
             ).replace('/src/', '/');

             const declarationFileDir = declarationPath
               .replace(basename(filename), '')
               .replace('/src/', '/');

             declarationFiles.push({
               declarationFileDir,
               declarationPath,
               data,
             });
           }
         },
         undefined /* cancellationToken */,
         undefined /* emitOnlyDtsFiles */,
         transformers,
       );

       writeFileCallback(id, content, false, undefined, [sourceFile]);
-      if (map !== undefined) {
+      if (map !== undefined && mapFilename) {
         writeFileCallback(
-          id.replace(/\.ts$/, '.js.map'),
+          mapFilename,
           map,
           false,
           undefined,
           [sourceFile],
         );
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts` around lines
1257 - 1314, The writeOutputFile function currently replaces the source id
extension to build the map filename (id.replace(/\.ts$/, '.js.map')), which
breaks for .cts/.mts; modify the builder.emit callback (inside writeOutputFile)
to capture the actual emitted map filename provided by the emit callback (e.g.,
store the filename when /\.[cm]?js\.map$/ matches into a variable like
emittedMapFilename alongside the map data) and then pass that captured
emittedMapFilename to writeFileCallback instead of the id-based replacement;
ensure you still set map = data and use the same sourceFile in the
writeFileCallback call so emitted maps for .ts/.cts/.mts are written with the
exact filenames TypeScript emitted.
🧹 Nitpick comments (3)
tests/vitest-angular/src/sourcemap/vitest.project.ts (1)

10-10: 💤 Low value

Double cast hides that angular() returns Plugin[], not Plugin.

angular(options) is typed as Plugin[]. Wrapping it in [...as unknown as Plugin] produces a Plugin[] whose first element is itself an array — Vite flattens nested plugin arrays at runtime so this works, but the cast obscures it and disables type checking. Spreading is clearer and keeps the type system honest:

-  plugins: [angular({ jit: false }) as unknown as Plugin],
+  plugins: [...angular({ jit: false })] as Plugin[],

Skip if the sibling projects already use the same as unknown as Plugin shape — repo consistency wins.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/vitest-angular/src/sourcemap/vitest.project.ts` at line 10, The plugins
array currently wraps angular({ jit: false }) with a double-cast to Plugin,
hiding that angular(...) actually returns Plugin[] and producing a nested array;
replace the casted single entry with a spread of the returned array (use
plugins: [...angular({ jit: false })]) and remove the unnecessary as unknown as
Plugin cast so TypeScript preserves the correct Plugin[] type for angular(),
unless you intentionally must match sibling projects' existing cast convention
for consistency.
tests/vitest-angular/src/sourcemap/sourcemap.spec.ts (2)

31-44: 💤 Low value

Test regression-bound to native Error.stack format — confirm the cross-runner story.

Error.stack line/column formatting differs between V8 (Chromium) and JavaScriptCore/Gecko. The Vitest browser config in vitest.config.ts only registers chromium, so today this is fine, but if anyone ever adds webkit/firefox instances the stack frame won't match sourcemap.spec.ts<...>:LINE and the test will start asserting not.toBeNull() against null. Worth a brief comment near the regex pinning the assumption to V8 stacks, or a fallback parser.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/vitest-angular/src/sourcemap/sourcemap.spec.ts` around lines 31 - 44,
The test "error stack maps back to the original TypeScript line" and the helper
parseSpecLine assume V8-style Error.stack formatting; update the code so this
assumption is explicit and robust: either add a brief comment next to
parseSpecLine explaining it pins to V8/Chromium stacks (so future contributors
know the limitation) and reference THROW_LINE/MAX_OFFSET in that comment, or
enhance parseSpecLine to detect and fall back to parsing WebKit/Gecko
stack-frame formats (accepting alternate regexes and returning the same line
number semantics) so the expect(...) checks remain valid across
chromium/firefox/webkit runners; modify parseSpecLine (and the test description
if needed) to reflect the chosen approach.

23-23: ⚡ Quick win

Hard-coded THROW_LINE = 34 is a maintenance trip-wire.

Any future reformatting (Prettier rule change, added import, edited block comment) of lines 1–33 silently shifts the throw line and either masks a regression or fails for the wrong reason. Since the goal is "stack reports a line close to the actual throw," capture the expected line at runtime instead — e.g., throw a probe error one line above and add 1. Keeps the test self-contained and resilient.

♻️ Self-anchoring throw line
-const THROW_LINE = 34;
-const MAX_OFFSET = 6;
+const MAX_OFFSET = 6;
+
+function currentLine(): number {
+  const m = /:(\d+)(?::\d+)?\)?$/.exec(new Error().stack?.split('\n')[2] ?? '');
+  return m ? Number(m[1]) : NaN;
+}
@@
 test('error stack maps back to the original TypeScript line', () => {
   let stack = '';
+  const expectedLine = currentLine() + 2;
   try {
     throw new Error('sourcemap probe');
   } catch (e) {
     stack = (e as Error).stack ?? '';
   }
   const reportedLine = parseSpecLine(stack);
   expect(reportedLine, `no spec frame in stack:\n${stack}`).not.toBeNull();
   expect(
-    Math.abs((reportedLine as number) - THROW_LINE),
-    `reported line ${reportedLine}, expected within ${MAX_OFFSET} of ${THROW_LINE}\n${stack}`,
+    Math.abs((reportedLine as number) - expectedLine),
+    `reported line ${reportedLine}, expected within ${MAX_OFFSET} of ${expectedLine}\n${stack}`,
   ).toBeLessThanOrEqual(MAX_OFFSET);
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/vitest-angular/src/sourcemap/sourcemap.spec.ts` at line 23, Replace the
hard-coded THROW_LINE constant with a runtime-computed value: create a probe
Error thrown exactly one line above the real throw, parse its stack to extract
the probe's line number, and set the expected throw line to (probeLine + 1);
update any references to THROW_LINE to use this computed value so the test
remains stable against edits/reformatting (target the existing THROW_LINE
constant and the test block that performs the actual throw).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts`:
- Around line 1230-1241: The current check uses _filename.endsWith('.map') and
thus can match declaration maps like foo.d.ts.map and overwrite the JS source
map in outputFiles; change the predicate in the block that upserts into
outputFiles (the branch handling map attachment for _filename) to only accept JS
source maps (e.g. restrict to /\.([cm]?js)\.map$/ or an equivalent endsWith
check for .js/.cjs/.mjs.map) so it matches the same map pattern used by the
writeOutputFile capture and prevents declaration maps from clobbering .js maps.

---

Outside diff comments:
In `@packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts`:
- Around line 1257-1314: The writeOutputFile function currently replaces the
source id extension to build the map filename (id.replace(/\.ts$/, '.js.map')),
which breaks for .cts/.mts; modify the builder.emit callback (inside
writeOutputFile) to capture the actual emitted map filename provided by the emit
callback (e.g., store the filename when /\.[cm]?js\.map$/ matches into a
variable like emittedMapFilename alongside the map data) and then pass that
captured emittedMapFilename to writeFileCallback instead of the id-based
replacement; ensure you still set map = data and use the same sourceFile in the
writeFileCallback call so emitted maps for .ts/.cts/.mts are written with the
exact filenames TypeScript emitted.

---

Nitpick comments:
In `@tests/vitest-angular/src/sourcemap/sourcemap.spec.ts`:
- Around line 31-44: The test "error stack maps back to the original TypeScript
line" and the helper parseSpecLine assume V8-style Error.stack formatting;
update the code so this assumption is explicit and robust: either add a brief
comment next to parseSpecLine explaining it pins to V8/Chromium stacks (so
future contributors know the limitation) and reference THROW_LINE/MAX_OFFSET in
that comment, or enhance parseSpecLine to detect and fall back to parsing
WebKit/Gecko stack-frame formats (accepting alternate regexes and returning the
same line number semantics) so the expect(...) checks remain valid across
chromium/firefox/webkit runners; modify parseSpecLine (and the test description
if needed) to reflect the chosen approach.
- Line 23: Replace the hard-coded THROW_LINE constant with a runtime-computed
value: create a probe Error thrown exactly one line above the real throw, parse
its stack to extract the probe's line number, and set the expected throw line to
(probeLine + 1); update any references to THROW_LINE to use this computed value
so the test remains stable against edits/reformatting (target the existing
THROW_LINE constant and the test block that performs the actual throw).

In `@tests/vitest-angular/src/sourcemap/vitest.project.ts`:
- Line 10: The plugins array currently wraps angular({ jit: false }) with a
double-cast to Plugin, hiding that angular(...) actually returns Plugin[] and
producing a nested array; replace the casted single entry with a spread of the
returned array (use plugins: [...angular({ jit: false })]) and remove the
unnecessary as unknown as Plugin cast so TypeScript preserves the correct
Plugin[] type for angular(), unless you intentionally must match sibling
projects' existing cast convention for consistency.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b8beb7dd-ecc7-4928-960d-83064b7b7419

📥 Commits

Reviewing files that changed from the base of the PR and between 17dd6bf and 80d4910.

📒 Files selected for processing (6)
  • packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts
  • packages/vite-plugin-angular/src/lib/angular-vitest-plugin.ts
  • tests/vitest-angular/src/sourcemap/sourcemap.spec.ts
  • tests/vitest-angular/src/sourcemap/test-setup.ts
  • tests/vitest-angular/src/sourcemap/vitest.project.ts
  • tests/vitest-angular/vitest.config.ts

Comment thread packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts
…ugh the Angular plugin

Switch TS emit from inline sourcemaps to separate .map files, capture the
map alongside the .js content in outputFiles, and return it from the
transform hook. Also strip the stale sourceMappingURL comment emitted by
TS so downstream tools don't see two references.

The .map and .js arrive via writeFileCallback in either order (TS in test
mode emits .map first), so upsert the entry without requiring a
pre-existing record. The previous version dropped the map silently when
.map arrived before .js.

In angularVitestSourcemapPlugin, accept a getInMap callback and skip the
post-OXC/esbuild transform when the upstream map is already available.
The post-pass exists to convert non-Angular .ts files to JS — for files
Angular already compiled, re-running OXC produces a map relative to the
TS-emitted JS rather than the original source, breaking the chain Vite
otherwise wires up. OXC's `inMap` parameter does not chain through the
way esbuild's inline `//# sourceMappingURL=` auto-detection does, so
skipping is the only reliable option on rolldown-backed Vite.

Adds an integration test under tests/vitest-angular/src/sourcemap/ that
asserts Error.stack reports a line close to the original .ts throw site.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@brandonroberts brandonroberts force-pushed the fix/vite-plugin-angular-preserve-sourcemaps branch from 80d4910 to a3a7556 Compare May 9, 2026 11:12
@brandonroberts brandonroberts merged commit 46c608f into beta May 9, 2026
21 checks passed
@brandonroberts brandonroberts deleted the fix/vite-plugin-angular-preserve-sourcemaps branch May 9, 2026 11:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope:vite-plugin-angular Changes in @analogjs/vite-plugin-angular scope:vitest-angular Changes in @analogjs/vitest-angular

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sourcemap out of sync with the actual code when testing

1 participant