Skip to content

webpack: build fails when a scoped style's base64 contains '/' β€” virtual-loader percent-decodes the filename paramΒ #55

Description

@lukemelia

[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:

  1. 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.

  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions