Skip to content

fix: resolve sourcemap positions to the correct stylesheet - #17

Open
linpengzhang wants to merge 8 commits into
MarioCadenas:mainfrom
linpengzhang:fix/concatenated-css-sourcemap-positions
Open

fix: resolve sourcemap positions to the correct stylesheet#17
linpengzhang wants to merge 8 commits into
MarioCadenas:mainfrom
linpengzhang:fix/concatenated-css-sourcemap-positions

Conversation

@linpengzhang

Copy link
Copy Markdown

TL;DR: the generated map named every stylesheet under sources but resolved every position back to the first one. This makes the mappings correct, and adds tests that check resolution rather than just the presence of a .map file.

The problem

Running the existing playground (11 imported CSS files) on main and tracing positions back through the emitted map:

sources[] in generated map : 12
  PASS  ...  0 of 5 probes
  FAIL "btn"   -> styles/variables.css:1   (want components/button.css)
  FAIL "card"  -> styles/variables.css:1   (want components/card.css)
  FAIL "form"  -> styles/variables.css:1   (want components/form.css)
  FAIL "modal" -> styles/variables.css:1   (want components/modal.css)
  FAIL "fade"  -> styles/variables.css:1   (want animations.css)

Everything collapses onto the first source. Two causes:

Composition vs. concatenation. A CSS asset is a concatenation of the stylesheets that went into it, but the per-stylesheet maps were combined with merge-source-map, which composes maps as a chain of transforms. The stylesheets sit side by side rather than each rewriting the previous one, so composition is the wrong operation.

Minification. Vite minifies the CSS asset after the plugin has recorded it. On main the built asset is 3 lines / 9406 bytes while the map holds a 6-line identity mapping, so the two no longer describe the same file.

The fix

Each stylesheet is located in the concatenated asset, and its mappings are translated into the coordinate space it actually occupies there. Columns are tracked as well as lines, because Vite can put one stylesheet’s closing } and the next one’s first rule on a single physical line — with column-0-only segments the earlier stylesheet wins every lookup on that line.

CSS minification is disabled by default while the plugin is active (disableCssMinify: false opts out, keeping minification and forgoing accurate maps). Without this the plugin silently emits a map that cannot be correct.

After, on the same playground:

  PASS "btn"   -> components/button.css:2
  PASS "card"  -> components/card.css:2
  PASS "form"  -> components/form.css:2
  PASS "modal" -> components/modal.css:2
  PASS "fade"  -> animations.css:4
  5/5 probes resolved correctly

Vite 8 / Rolldown

Locating stylesheets by position removes the need to proxy augmentChunkHash on vite:css-post to predict asset names, which was producing .map files whose hash did not match the asset. Emission now runs after vite:css-post and reads the real asset name.

Verified on Vite 8.0.8 (Rolldown) with a Tailwind v4 entry, plain CSS, a CSS module and SCSS in one build: sibling name matches the asset, sourceMappingURL is injected, and 7/7 probes resolve to the right stylesheet — including SCSS partials landing on their true selector lines via the existing Sass path.

Tests

The suite asserted only that a .map file existed and was referenced, which stayed true while the mappings inside were meaningless. Added two integration tests that trace known selectors back through the map and assert mappings spread across every source; both fail on main and pass here.

The should handle custom folder option test was marked it.skip with a TODO: Fix this test — the old design made it hard to drive in isolation. It is now un-skipped and passing, and should handle custom sourcemap URL function now actually asserts on getURL output rather than on an unrelated emitFile call.

npm run ci:build is green: 21 passing, none skipped (was 15 passing + 1 skipped).

Notes

  • Swaps the merge-source-map dependency for @jridgewell/sourcemap-codec.
  • No changes to the existing public options.

Fixes #7, #8, #13, #16.

Made with Cursor

linpengzhang and others added 2 commits September 3, 2026 12:39
A CSS asset is a concatenation of the stylesheets that went into it, but the
individual sourcemaps were combined with merge-source-map, which composes maps
as a chain of transforms. Composition is the wrong operation here: the
stylesheets sit side by side rather than each rewriting the previous one, so
the combined map named every source while resolving every position back to the
first one.

Each stylesheet's mappings are now translated into the coordinate space it
actually occupies in the asset, found by locating its compiled CSS in the
output. Tracking the column as well as the line matters, because Vite can place
one stylesheet's last line and the next one's first line on a single physical
line.

CSS minification is also disabled while the plugin is active. Vite minifies the
asset after the plugin has recorded those positions, collapsing it onto a few
lines and invalidating all of them; this was the other reason every lookup
landed on the first source. Set disableCssMinify: false to opt out.

Locating stylesheets by position also removes the need to proxy the
augmentChunkHash hook of vite:css-post to predict asset names, which is what
produced .map files whose hash did not match the asset they belonged to on
Vite 8. Emission now runs after vite:css-post and reads the real asset name,
so the map is a correct sibling and the sourceMappingURL comment is injected.

The existing tests asserted only that a .map file existed and was referenced,
which held while the mappings inside it were meaningless. The new integration
tests resolve known selectors back through the map and assert the mappings are
spread across every source.

Fixes MarioCadenas#7
Fixes MarioCadenas#8
Fixes MarioCadenas#13
Fixes MarioCadenas#16

Co-authored-by: Cursor <cursoragent@cursor.com>
Two stylesheets can compile to byte-identical CSS, in which case both claimed
the first occurrence in the asset. One of them was then unreachable through the
map and its positions resolved to the other file. Each stylesheet now takes an
occurrence no other has claimed.

Co-authored-by: Cursor <cursoragent@cursor.com>
@linpengzhang

Copy link
Copy Markdown
Author

@MarioCadenas whenever you have time — this closes #7, #8, #13 and #16. The short version is that the combined map named every stylesheet under sources while resolving every position back to the first one, so the existing tests (which only checked that a .map existed and was referenced) stayed green throughout. Added integration tests that trace selectors back through the map and fail on main.

@fablrdigital @neclimdul @thany @hatarakiman — you each filed one of these. If you can try the branch against your setup and confirm it covers your case, that would help move it along:

npm i -D MarioCadenas/vite-plugin-css-sourcemap#fix/concatenated-css-sourcemap-positions

No rush on my end, happy to split it into smaller PRs or adjust the approach if you would rather take it a different way.

linpengzhang and others added 4 commits September 3, 2026 13:04
Three cases were still missing from the concatenated sourcemap:

- A url() reference is an unresolved __VITE_ASSET__ placeholder when the
  plugin captures a stylesheet, and vite:css-post substitutes the hashed URL
  afterwards, so searching the finished asset for the captured text never
  matched. Any stylesheet referencing an image or font was dropped.
- Claiming a start offset rather than a whole region let a stylesheet whose
  compiled CSS is contained in another's take a position inside it. Regions
  are now claimed whole, longest first.
- file:// sources went through URL.pathname, which leaves a Windows path as
  /C:/... and keeps percent-encoding; they now use fileURLToPath. Styles from
  single-file components also record the file rather than the queried id.

Co-authored-by: Cursor <cursoragent@cursor.com>
Vite's asset token holds a query or fragment in a trailing $_...__ group, so
url('sprite.svg#star') becomes __VITE_ASSET__<id>__$_#star__. Matching only
the reference id left that suffix in the search pattern, which no longer
appears once the real URL is substituted, and the stylesheet was dropped.

Co-authored-by: Cursor <cursoragent@cursor.com>
Vite lifts @charset and @import to the top of a concatenated asset, so a
stylesheet opening with a webfont import is split apart and its compiled text
never appears as one run. Placement now retries without those leading at-rules
and shifts the mappings by the lines they occupied.

Placement also searched every captured stylesheet inside every CSS asset. With
cssCodeSplit that let one chunk's stylesheet claim the identical region in
another chunk's asset, so its twin went unmapped and its coverage landed on the
wrong file. Each asset is now searched only against the stylesheets the chunk
that pulled it in was built from.

Co-authored-by: Cursor <cursoragent@cursor.com>
A webfont import asks for several weights with semicolons inside the URL —
family=Inter:wght@400;700 — and a data URI can hold any number of them. Taking
the at-rule to end at the first semicolon left the statement half stripped, so
the stylesheet was dropped from the map. It is now scanned with quotes and
parens accounted for.

Co-authored-by: Cursor <cursoragent@cursor.com>
@MarioCadenas

MarioCadenas commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Thanks for this @linpengzhang, the diagnosis is right (composition vs concatenation, plus minify invalidating the recorded positions) and the new tests actually check resolution instead of just that a .map exists.

A few things I wouldn't merge around:

  • Trim vs mapping offsets. locateStylesheets searches stylesheet.code.trim() but skippedLines only accounts for hoisted @charset / @import. Leading blank lines (and first-line indent) get dropped without shifting the decoded mappings, so those sources still resolve to the wrong line/column. Same class of bug this PR is fixing, just at the placement step.
  • SCSS fallback. compileSCSS swallows every error and falls back to a 1:1 map. Vite aliases or a sass-embedded-only setup will silently get a useless map. At least warn; ideally don't compile outside Vite's resolver.
  • countLines in generateBundle. That's a readFileSync just to count source lines when there's no map. We already have stylesheet.code from transform — don't hit the filesystem there.

Smaller / follow-ups:

  • endOfStatement doesn't skip /* */, so a comment containing ; can drop a hoisted-at-rule stylesheet
  • resolveSource ignores sourceRoot
  • decode() isn't guarded; a bad upstream map would fail the build
  • Placement is a full-asset scan per stylesheet (plus a regex when there are __VITE_ASSET__ placeholders). Fine for typical apps, worth keeping an eye on if someone has a huge CSS graph

On disableCssMinify defaulting to true: I'm fine with that given the alternative is a map that lies. The option name is a bit inverted (disableCssMinify: false keeps minify on) but the README covers it.

Searching code.trim() without shifting mappings put rules on the wrong
line. Snapshot the source file's line count at transform time, use Vite's
combined map instead of compiling Sass again, and guard decode / comments
/ sourceRoot so a bad upstream map cannot fail the build.

Co-authored-by: Cursor <cursoragent@cursor.com>
@linpengzhang

linpengzhang commented Sep 7, 2026

Copy link
Copy Markdown
Author

Thanks @MarioCadenas for the detailed review — addressed on this branch.

  • Trim vs mapping offsets. leadingTrim() records the newlines and first-line indent that trim() strips, and those offsets are applied when the preprocessor map is shifted into the asset. Added tests for leading blank lines and first-line indent.
  • SCSS fallback. Removed compileSCSS. We only use Vite's getCombinedSourcemap(). A raw SCSS Rollup entry that Vite doesn't expose a combined map for is attributed to the entry file instead of a second Sass compile that silently went 1:1 on aliases / sass-embedded.
  • countLines in generateBundle. Line count is snapshotted at transform from the source file (sourceLineCount). I didn't use stylesheet.code — after Tailwind (and similar generators) that string is the compiled output, so clamping to it invents thousands of source lines.
  • endOfStatement / comments. Skips /* */ so a semicolon inside a comment is not treated as the end of an at-rule.
  • sourceRoot. resolveSource now prepends it for relative sources.
  • Unguarded decode(). try/caught; a bad upstream map warns and falls back instead of failing the build.
  • Placement scan. Stopped compiling the captured CSS into one RegExp for __VITE_ASSET__ placeholders. Tailwind's @layer properties (lots of ()) threw Invalid regular expression. It's a string search now, with a Tailwind-sized test.

Left disableCssMinify as-is: the default is right, and renaming it would be a breaking public option. The README already covers disableCssMinify: false.

Compiling a Tailwind-sized stylesheet into one regular expression throws
Invalid regular expression. Match __VITE_ASSET__ gaps as literals instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sourcemap is not being output correctly

2 participants