Skip to content

feat(unstable): CSS module imports (with { type: "css" })#35093

Open
bartlomieju wants to merge 8 commits into
mainfrom
prototype/css-module-imports
Open

feat(unstable): CSS module imports (with { type: "css" })#35093
bartlomieju wants to merge 8 commits into
mainfrom
prototype/css-module-imports

Conversation

@bartlomieju

@bartlomieju bartlomieju commented Jun 10, 2026

Copy link
Copy Markdown
Member

Prototype of CSS module scripts behind --unstable-raw-imports, towards
#11961. Importing a stylesheet with with { type: "css" } (statically or
dynamically) evaluates to a CSSStyleSheet containing the source text,
matching what Chrome and Firefox ship. The main use case is running
unmodified browser module graphs in Deno (SSR and testing of web
components), where a CSS import currently kills module loading before any
code runs.

The CSSStyleSheet implementation is intentionally minimal: it is backed
by the raw CSS text, supports replace()/replaceSync(), and cssRules
performs a naive top-level rule split (no real CSS parsing) so rules can
be serialized back out for SSR. The global is only installed when the
raw-imports unstable feature is enabled. On the loader side the requested
module type rides the same asset path as text/bytes imports, and deno_core's
custom_module_evaluation_cb creates the stylesheet. Graph building records
the css asset edge directly now that deno_graph accepts css asset
attributes (gated on the unstable flag via unstable_css_imports); this
bumps deno_graph to 0.109.0 and deno_doc to 0.200.0.

Implements a minimal prototype of CSS module scripts behind
--unstable-raw-imports. Importing a stylesheet with
'with { type: "css" }' (statically or dynamically) evaluates to a
CSSStyleSheet whose default export contains the source text.

The CSSStyleSheet implementation is intentionally minimal: it is backed
by the raw CSS text, supports replace()/replaceSync(), and cssRules
performs a naive top-level rule split (no real CSS parsing) so rules can
be serialized back out (e.g. for SSR). The global is only installed when
the raw-imports unstable feature is enabled.

Plumbing: the import attribute is validated in the runtime when raw
imports are enabled, deno_core's custom_module_evaluation_cb creates the
stylesheet for ModuleType::Other("css"), and the CLI loader treats the
requested type like a text asset. Since deno_graph only knows text/bytes
asset imports, the module analyzer remaps a 'css' attribute to 'text'
(gated on the flag) so graph building records an asset edge; a real
implementation should make asset attribute types pluggable in
deno_graph.
Replaces the module analyzer hack that remapped 'css' import attributes
to 'text' with deno_graph's new unstable_css_imports option. deno_doc is
temporarily pulled from a git branch that uses deno_graph 0.109
(denoland/deno_doc#817); the patch will be
removed once a new deno_doc version is released.
deno_doc 0.200.0 is released with deno_graph 0.109, so the temporary
[patch.crates-io] entry is no longer needed.
@bartlomieju bartlomieju marked this pull request as ready for review June 10, 2026 13:20

@bartlomieju bartlomieju left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to land — CI is green, scoped behind `--unstable-raw-imports`, and test coverage is thorough. Two minor non-blocking notes:

  1. CSSRule is declared as a runtime global in lib.deno.unstable.d.ts but never installed at runtime. 98_global_scope_shared.js only adds CSSStyleSheet to unstableForWindowOrWorkerGlobalScope[rawImports], so x instanceof CSSRule type-checks but throws ReferenceError at runtime. Browsers do expose CSSRule globally. Either expose it too, or drop the declare var CSSRule value declaration and keep only the interface. The tests do not catch this because they only use CSSRule as a type.

  2. replace() diverges from spec — it does not strip @import rules and resolves synchronously. Fine for a minimal prototype (and documented in the source comment), but worth a follow-up since the real API has observable @import-stripping behavior.

Neither is a reason to hold the PR; #1 is a one-liner you could fold in or chase later.

- Install CSSRule as a global alongside CSSStyleSheet under the
  raw-imports unstable scope; previously lib.deno.unstable.d.ts declared
  it as a runtime value but it was never exposed, so "x instanceof
  CSSRule" type-checked but threw ReferenceError at runtime.
- Constructed style sheets disallow @import, so replace()/replaceSync()
  now drop top-level @import rules (matching the CSSOM spec) instead of
  storing them verbatim. The CSS module import path is unchanged and
  still preserves @import for SSR serialization.
@bartlomieju

Copy link
Copy Markdown
Member Author

One small WebIDL nit on CSSStyleSheet.prototype.replace:

The argument-conversion error path is handled correctly — a bad first argument
(e.g. one that fails the DOMString conversion) produces a rejected promise
rather than a synchronous throw, which is what WebIDL requires for a
promise-returning operation.

However, a zero-argument call (sheet.replace()) currently throws
synchronously because of #[required(1)]. Per the WebIDL spec, the "not
enough arguments" TypeError for a promise-returning operation should also be
turned into a rejected promise, not a synchronous exception. So today:

sheet.replace("ok");   // resolves  ✅
sheet.replace(badArg); // rejects   ✅ (correct)
sheet.replace();       // throws synchronously  ❌ (spec wants a rejected promise)

Minor and arguably fine for an unstable minimal implementation, but it'd be
worth either matching the spec (reject instead of throw on missing arg) or
leaving a comment noting the intentional deviation, plus a test pinning the
behavior either way.

`replace` returns a promise, so per WebIDL a "not enough arguments"
TypeError must be turned into a rejected promise rather than thrown
synchronously. It previously used `#[required(1)]`, which throws
synchronously before the op body runs, so `sheet.replace()` threw
instead of rejecting. Drop `#[required(1)]` and check the argument
count manually, rejecting with the same message `#[required(1)]` would
have produced. `replaceSync` keeps `#[required(1)]` since it is a
synchronous operation. Adds a spec test pinning the resolve, invalid
argument, and missing argument paths.

@crowlKats crowlKats left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, cool to have!

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.

2 participants