fix: resolve sourcemap positions to the correct stylesheet - #17
fix: resolve sourcemap positions to the correct stylesheet#17linpengzhang wants to merge 8 commits into
Conversation
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>
|
@MarioCadenas whenever you have time — this closes #7, #8, #13 and #16. The short version is that the combined map named every stylesheet under @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: 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. |
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>
|
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 A few things I wouldn't merge around:
Smaller / follow-ups:
On |
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>
|
Thanks @MarioCadenas for the detailed review — addressed on this branch.
Left |
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>
TL;DR: the generated map named every stylesheet under
sourcesbut 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.mapfile.The problem
Running the existing
playground(11 imported CSS files) onmainand tracing positions back through the emitted map: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
mainthe 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: falseopts out, keeping minification and forgoing accurate maps). Without this the plugin silently emits a map that cannot be correct.After, on the same playground:
Vite 8 / Rolldown
Locating stylesheets by position removes the need to proxy
augmentChunkHashonvite:css-postto predict asset names, which was producing.mapfiles whose hash did not match the asset. Emission now runs aftervite:css-postand 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,
sourceMappingURLis 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
.mapfile 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 onmainand pass here.The
should handle custom folder optiontest was markedit.skipwith aTODO: Fix this test— the old design made it hard to drive in isolation. It is now un-skipped and passing, andshould handle custom sourcemap URL functionnow actually asserts ongetURLoutput rather than on an unrelatedemitFilecall.npm run ci:buildis green: 21 passing, none skipped (was 15 passing + 1 skipped).Notes
merge-source-mapdependency for@jridgewell/sourcemap-codec.Fixes #7, #8, #13, #16.
Made with Cursor