Skip to content

CSS url() references are corrupted into invalid JS expressions by asset URL transform #697

@valentinbeggi

Description

@valentinbeggi

Summary

The assetBaseUrlTransform plugin (dist/server/asset-base-url-transform-plugin.js) runs a source-level regex over every module and rewrites any quoted asset path into (window.skybridge?.serverUrl ?? "") + "/path". Because Vite serves .css imports as JS modules with the CSS embedded as string literals, the regex also matches inside those strings — and the rewritten output is invalid CSS once it's injected into a <style> tag.

Net effect: every @font-face / background-image / mask-image / any url("/foo.<woff2|woff|svg|png|jpg|jpeg|gif|webp|mp3|mp4|ttf|eot>") silently fails to load in dev, with the asset URL itself being perfectly reachable.

Repro

  1. Scaffold a fresh Skybridge project.
  2. Drop any matching file into web/public/fonts/Brand.woff2.
  3. Add web/src/fonts.css:
    @font-face {
      font-family: "Brand";
      src: url("/fonts/Brand.woff2") format("woff2");
    }
  4. import "./fonts.css" from the widget entry.
  5. pnpm dev, open the widget, inspect the font.

Expected: "Brand" loads.
Actual: font is missing. Fetching the CSS module that Vite serves shows the src: line transformed to:

src: url((window.skybridge?.serverUrl ?? "") + "/assets/fonts/Brand.woff2") format("woff2");

…which is not valid CSS. curl http://localhost:3000/assets/fonts/Brand.woff2 returns 200 OK — the asset is reachable, only the reference is broken.

Root cause

dist/server/asset-base-url-transform-plugin.js:

const assetStringPattern = /(?<!https?:\/\/)(["'`])(\/[^"'`]+\.(svg|png|jpeg|jpg|gif|webp|mp3|mp4|woff|woff2|ttf|eot))\1/g;
code = code.replace(assetStringPattern, (_match, _quote, assetPath) => {
    return `(window.skybridge?.serverUrl ?? "") + "${assetPath}"`;
});

The transform hook runs over every module's source without checking the module type. Vite wraps CSS imports as JS modules where the stylesheet is passed as a string to __vite__updateStyle(...). The regex matches asset paths inside those string literals and rewrites them into JS concatenation expressions, which are syntactically invalid inside a CSS url(...).

Impact

  • Silent: the browser drops the unresolvable reference and falls back to defaults — no hard error in the console beyond occasional NetworkError.
  • Surface: any CSS file authored in web/src/** that references assets by absolute path.
  • Workaround requires moving both the CSS file AND its assets into public/ and injecting via <link rel="stylesheet">, which bypasses the Vite asset pipeline entirely (no hashing, no pruning).

Suggested fix

The same plugin file already has the correct mechanism via experimental.renderBuiltUrl in web/plugin/plugin.js — that hook is context-aware and works for both JS and CSS referents. The regex-based source transform looks redundant for most cases and wrong for CSS. Options in order of preference:

  1. Remove the source-level transform, rely solely on renderBuiltUrl. Needs verification that dev-mode runtime URLs still work.
  2. Skip CSS in the transform hook: in transform(code, id), bail when id indicates a CSS-ish module (.css, .module.css, ?direct, ?inline, ?raw, framework-specific variants).
  3. Constrain the regex to not match inside url(...). Fragile; (1) or (2) is cleaner.

Happy to open a PR if a direction is picked.

Environment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions