[Claude Code π€] β investigated and filed by Claude on behalf of @lukemelia.
Symptom
With the webpack integration (GlimmerScopedCSSWebpackPlugin), certain <style scoped> contents make the build fail with a ModuleNotFoundError for css-loader's runtime, with one-too-many ../ segments in the relative path:
ModuleNotFoundError: Module not found: Error: Can't resolve
'../../../../../../../../node_modules/.pnpm/css-loader@5.2.7_webpack@5.99.6/node_modules/css-loader/dist/runtime/cssWithMappingToString.js'
in '/path/to/app/components/some-component'
What makes this maddening to track down is that the trigger looks supernatural: adding a ? to a CSS comment breaks the build (real example in cardstack/boxel β the whole change is a two-line comment), and in another case padding: 0 0 1.5px 1.5px failed while padding: 0 0 1.49px 1.5px built fine.
Root cause
The actual trigger is: the build breaks whenever btoa(css) of a scoped style block contains a / character.
encodeCSS produces encodeURIComponent(btoa(css)), so a base64 / becomes %2F in the virtual import path:
./example.gts.LmEgeyBjb2xvcjogcmVkOyB9IC8qIHdoeT8%2FPyAqLw%3D%3D.glimmer-scoped.css
In webpack.ts, the plugin embeds that path verbatim into a loader query string:
state.request = `style-loader!css-loader!glimmer-scoped-css/virtual-loader?filename=${resolve(
dirname(state.contextInfo.issuer),
state.request
)}!`;
Then virtual-loader.ts parses the query with new URLSearchParams(optionsString) β which percent-decodes the value. The %2F comes back as a literal /, so the "filename" now contains a bogus extra path separator in the middle of the base64 blob:
const css = '.a { color: red; } /* why??? */'; // btoa(css) contains '/'
const enc = encodeURIComponent(btoa(css));
const request = '/app/components/example.gts.' + enc + '.glimmer-scoped.css';
new URLSearchParams('filename=' + request).get('filename');
// => '/app/components/example.gts.LmEgeyBjb2xvcjogcmVkOyB9IC8qIHdoeT8/PyAqLw==.glimmer-scoped.css'
// extra literal slash from %2F βββββββββββββββββββββ
path.dirname(that);
// => '/app/components/example.gts.LmEgeyBjb2xvcjogcmVkOyB9IC8qIHdoeT8' β bogus, one level too deep
The virtual loader then does this.resourcePath = filename; this.context = path.dirname(filename), so the loader context is one directory deeper than reality (one extra level per / in the base64). css-loader, downstream in the same chain, computes its runtime import (cssWithMappingToString.js on css-loader 5) relative to that corrupted context β emitting one extra ../ per stray slash.
But when webpack later resolves that emitted import, identifyVirtualFile in webpack.ts assigns the correct context (decodeScopedCSSRequest(filename).fromFile strips the encoded section entirely). Generated against the deep bogus context, resolved against the correct shallow one β the ../ count overshoots β ModuleNotFoundError. The error above has exactly one extra ../, matching exactly one / in that style block's base64.
Why the trigger looks random
Base64 / is the 6-bit group 111111. ASCII CSS rarely produces six consecutive 1-bits, but ? is 0x3F = 00111111 β when the 3-byte alignment puts its low six bits into one base64 group, you get /. That's why adding a ? to a comment can break the build, and why inserting/removing any character shifts the alignment of everything after it and can toggle the failure (the 1.5px β 1.49px "fix"). For long style blocks the failure probability approaches 1 - (63/64)^(len/0.75), so this bites more as styles grow.
Minimal repro
Any component with:
<style scoped>
.a { color: red; } /* why??? */
</style>
btoa('.a { color: red; } /* why??? */') = LmEgeyBjb2xvcjogcmVkOyB9IC8qIHdoeT8/PyAqLw== β contains a /, so the encoded request contains %2F and the webpack build fails as above. (Verified against glimmer-scoped-css 0.8.1, webpack 5.99, css-loader 5.2.7, via Embroider.)
The rollup plugin is not affected: rollup.ts calls decodeScopedCSSRequest directly on the import specifier (one decodeURIComponent matching the one encodeURIComponent) and resolves to a clean md5 id β no query-string round-trip.
Suggested fix
Two options, not mutually exclusive:
-
Symmetric escaping in the webpack plugin β encode the filename when building the loader request, so the URLSearchParams decode restores it exactly (the % of %2F becomes %252F in transit):
state.request = `style-loader!css-loader!glimmer-scoped-css/virtual-loader?filename=${encodeURIComponent(
resolve(dirname(state.contextInfo.issuer), state.request)
)}!`;
virtual-loader.ts and identifyVirtualFile both already decode once via URLSearchParams, so they'd now receive the intact encoded path, and decodeCSS's own decodeURIComponent + atob works as designed.
-
Use URL-safe base64 in encoding.ts (+ β -, / β _, drop = padding). Then the encoded section contains no percent-sequences at all, so no decode-count mismatch can corrupt it anywhere β webpack, rollup, or future integrations. decodeCSS could accept both alphabets during a transition. This also makes the virtual ids friendlier to anything else that URL-decodes module ids (dev servers, source maps).
Option 2 removes the entire class of bug; option 1 is the minimal targeted fix.
[Claude Code π€] β investigated and filed by Claude on behalf of @lukemelia.
Symptom
With the webpack integration (
GlimmerScopedCSSWebpackPlugin), certain<style scoped>contents make the build fail with aModuleNotFoundErrorfor css-loader's runtime, with one-too-many../segments in the relative path:What makes this maddening to track down is that the trigger looks supernatural: adding a
?to a CSS comment breaks the build (real example in cardstack/boxel β the whole change is a two-line comment), and in another casepadding: 0 0 1.5px 1.5pxfailed whilepadding: 0 0 1.49px 1.5pxbuilt fine.Root cause
The actual trigger is: the build breaks whenever
btoa(css)of a scoped style block contains a/character.encodeCSSproducesencodeURIComponent(btoa(css)), so a base64/becomes%2Fin the virtual import path:In
webpack.ts, the plugin embeds that path verbatim into a loader query string:Then
virtual-loader.tsparses the query withnew URLSearchParams(optionsString)β which percent-decodes the value. The%2Fcomes back as a literal/, so the "filename" now contains a bogus extra path separator in the middle of the base64 blob:The virtual loader then does
this.resourcePath = filename; this.context = path.dirname(filename), so the loader context is one directory deeper than reality (one extra level per/in the base64). css-loader, downstream in the same chain, computes its runtime import (cssWithMappingToString.json css-loader 5) relative to that corrupted context β emitting one extra../per stray slash.But when webpack later resolves that emitted import,
identifyVirtualFileinwebpack.tsassigns the correct context (decodeScopedCSSRequest(filename).fromFilestrips the encoded section entirely). Generated against the deep bogus context, resolved against the correct shallow one β the../count overshoots βModuleNotFoundError. The error above has exactly one extra../, matching exactly one/in that style block's base64.Why the trigger looks random
Base64
/is the 6-bit group111111. ASCII CSS rarely produces six consecutive 1-bits, but?is0x3F=00111111β when the 3-byte alignment puts its low six bits into one base64 group, you get/. That's why adding a?to a comment can break the build, and why inserting/removing any character shifts the alignment of everything after it and can toggle the failure (the1.5pxβ1.49px"fix"). For long style blocks the failure probability approaches1 - (63/64)^(len/0.75), so this bites more as styles grow.Minimal repro
Any component with:
btoa('.a { color: red; } /* why??? */')=LmEgeyBjb2xvcjogcmVkOyB9IC8qIHdoeT8/PyAqLw==β contains a/, so the encoded request contains%2Fand the webpack build fails as above. (Verified against glimmer-scoped-css 0.8.1, webpack 5.99, css-loader 5.2.7, via Embroider.)The rollup plugin is not affected:
rollup.tscallsdecodeScopedCSSRequestdirectly on the import specifier (onedecodeURIComponentmatching the oneencodeURIComponent) and resolves to a clean md5 id β no query-string round-trip.Suggested fix
Two options, not mutually exclusive:
Symmetric escaping in the webpack plugin β encode the filename when building the loader request, so the
URLSearchParamsdecode restores it exactly (the%of%2Fbecomes%252Fin transit):virtual-loader.tsandidentifyVirtualFileboth already decode once viaURLSearchParams, so they'd now receive the intact encoded path, anddecodeCSS's owndecodeURIComponent+atobworks as designed.Use URL-safe base64 in
encoding.ts(+β-,/β_, drop=padding). Then the encoded section contains no percent-sequences at all, so no decode-count mismatch can corrupt it anywhere β webpack, rollup, or future integrations.decodeCSScould accept both alphabets during a transition. This also makes the virtual ids friendlier to anything else that URL-decodes module ids (dev servers, source maps).Option 2 removes the entire class of bug; option 1 is the minimal targeted fix.