Skip to content

Optimize class attributes built from CSS module classes - #3331

Open
DylanPiercey wants to merge 1 commit into
mainfrom
claude/marko-idle-review-feedback-7ri0ar
Open

Optimize class attributes built from CSS module classes#3331
DylanPiercey wants to merge 1 commit into
mainfrom
claude/marko-idle-review-feedback-7ri0ar

Conversation

@DylanPiercey

Copy link
Copy Markdown
Contributor

Description

A class value assembled from CSS module classes (a stylesheet import or a <style/name> object) previously bailed out of the compile-time optimizer, since styles.foo is a member expression the constant folder can't resolve, so the whole array/object was handed to the runtime classValue walker on every render.

This recognizes a member access on a CSS module as a known class string (the value is filled in by the bundler; we only need to know it's a string) and treats these like static tokens:

  • HTML concatenates the classes directly instead of allocating and walking an array/object.
  • DOM applies constant classes once at mount and toggles the conditional ones in place, pulling the constants out of the reactive signal.
  • _attr_class_item now handles a class that resolves to multiple space-separated tokens (eg from composes:), which classList.toggle would otherwise reject.

Detection covers both a default/namespace import from a stylesheet source and a <style/var> object, and is phase-consistent (the <style> var is registered in analyze, before translate removes the tag).

In development each CSS module class access is wrapped so a non-string (a typo or a class missing from the stylesheet) logs a warning; the wrapper is compiled away in optimized builds.

The DOM runtime grows ~105 bytes min / 41 brotli, from the _attr_class_item multi-token handling and the debug-only assertion helper.

Checklist:

  • I have read the CONTRIBUTING document and have signed (or will sign) the CLA.
  • I have updated/added documentation affected by my changes.
  • I have added tests to cover my changes.

Generated by Claude Code

@changeset-bot

changeset-bot Bot commented Jul 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: efbb1a7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@marko/runtime-tags Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@codecov

codecov Bot commented Jul 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.43011% with 17 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.30%. Comparing base (98ea020) to head (efbb1a7).

Files with missing lines Patch % Lines
...ntime-tags/src/translator/util/css-module-class.ts 88.23% 9 Missing and 1 partial ⚠️
...ime-tags/src/translator/visitors/tag/native-tag.ts 97.60% 6 Missing ⚠️
packages/runtime-tags/src/dom.ts 0.00% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##             main    #3331    +/-   ##
========================================
  Coverage   94.29%   94.30%            
========================================
  Files         388      389     +1     
  Lines       53080    53401   +321     
  Branches     4231     4296    +65     
========================================
+ Hits        50051    50358   +307     
- Misses       3001     3013    +12     
- Partials       28       30     +2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

A `class` value assembled from CSS module classes (a stylesheet import or a
`<style/name>` object) previously bailed to the runtime `classValue` walker,
since `styles.foo` is a member expression the constant folder can't resolve.

Recognize these as known class strings so the optimizer can treat them like
static tokens: HTML concatenates them directly, DOM applies constant classes
once at mount and toggles the conditional ones in place, and `_attr_class_item`
handles a class that resolves to multiple tokens (eg from `composes:`). In
development a class that resolves to a non-string logs a warning; the wrapper is
compiled away in optimized builds.
@DylanPiercey
DylanPiercey force-pushed the claude/marko-idle-review-feedback-7ri0ar branch from 55ed6b4 to efbb1a7 Compare July 3, 2026 22:18
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 718bef63-2274-4858-814c-f2841fbc70e0

📥 Commits

Reviewing files that changed from the base of the PR and between 55ed6b4 and efbb1a7.

⛔ Files ignored due to path filters (6)
  • packages/runtime-tags/src/__tests__/fixtures/attr-class-css-module-style/__snapshots__/dom.bundle.debug.js is excluded by !**/__snapshots__/** and included by **
  • packages/runtime-tags/src/__tests__/fixtures/attr-class-css-module-style/__snapshots__/html.bundle.debug.js is excluded by !**/__snapshots__/** and included by **
  • packages/runtime-tags/src/__tests__/fixtures/attr-class-css-module-style/__snapshots__/html.bundle.js is excluded by !**/__snapshots__/** and included by **
  • packages/runtime-tags/src/__tests__/fixtures/attr-class-css-module/__snapshots__/dom.bundle.debug.js is excluded by !**/__snapshots__/** and included by **
  • packages/runtime-tags/src/__tests__/fixtures/attr-class-css-module/__snapshots__/html.bundle.debug.js is excluded by !**/__snapshots__/** and included by **
  • packages/runtime-tags/src/__tests__/fixtures/attr-class-css-module/__snapshots__/html.bundle.js is excluded by !**/__snapshots__/** and included by **
📒 Files selected for processing (18)
  • .changeset/css-module-class-optimization.md
  • .sizes.json
  • .sizes/dom.js
  • packages/runtime-tags/src/__tests__/fixtures/attr-class-css-module-style/sizes.json
  • packages/runtime-tags/src/__tests__/fixtures/attr-class-css-module-style/template.marko
  • packages/runtime-tags/src/__tests__/fixtures/attr-class-css-module-style/test.ts
  • packages/runtime-tags/src/__tests__/fixtures/attr-class-css-module/sizes.json
  • packages/runtime-tags/src/__tests__/fixtures/attr-class-css-module/styles.module.css
  • packages/runtime-tags/src/__tests__/fixtures/attr-class-css-module/template.marko
  • packages/runtime-tags/src/__tests__/fixtures/attr-class-css-module/test.ts
  • packages/runtime-tags/src/__tests__/fixtures/define-tag-for-attribute-tag/sizes.json
  • packages/runtime-tags/src/common/helpers.ts
  • packages/runtime-tags/src/dom.ts
  • packages/runtime-tags/src/dom/dom.ts
  • packages/runtime-tags/src/html.ts
  • packages/runtime-tags/src/translator/core/style.ts
  • packages/runtime-tags/src/translator/util/css-module-class.ts
  • packages/runtime-tags/src/translator/visitors/tag/native-tag.ts
✅ Files skipped from review due to trivial changes (9)
  • packages/runtime-tags/src/html.ts
  • packages/runtime-tags/src/tests/fixtures/define-tag-for-attribute-tag/sizes.json
  • packages/runtime-tags/src/dom.ts
  • packages/runtime-tags/src/tests/fixtures/attr-class-css-module/sizes.json
  • packages/runtime-tags/src/tests/fixtures/attr-class-css-module-style/sizes.json
  • .sizes.json
  • packages/runtime-tags/src/tests/fixtures/attr-class-css-module-style/template.marko
  • packages/runtime-tags/src/tests/fixtures/attr-class-css-module/styles.module.css
  • .changeset/css-module-class-optimization.md
🚧 Files skipped from review as they are similar to previous changes (9)
  • packages/runtime-tags/src/tests/fixtures/attr-class-css-module/test.ts
  • packages/runtime-tags/src/tests/fixtures/attr-class-css-module-style/test.ts
  • packages/runtime-tags/src/tests/fixtures/attr-class-css-module/template.marko
  • .sizes/dom.js
  • packages/runtime-tags/src/dom/dom.ts
  • packages/runtime-tags/src/translator/core/style.ts
  • packages/runtime-tags/src/translator/util/css-module-class.ts
  • packages/runtime-tags/src/common/helpers.ts
  • packages/runtime-tags/src/translator/visitors/tag/native-tag.ts

Walkthrough

This PR optimizes class attribute generation for CSS module accesses. The compiler now detects member expressions referencing CSS module stylesheets or <style/name> tags and treats them as known class strings, folding constant portions into a single string and updating only dynamic toggle portions at runtime. Runtime helpers _class_module_value and an updated _attr_class_item support dev-only validation and multi-token class names. Translator changes, fixtures, changeset documentation, and size output updates accompany the change.

Changes

Area Change
Compiler utilities New CSS module class detection helpers and style variable registration
Translator style.ts registers style module vars; native-tag.ts refactors class attribute codegen
Runtime _class_module_value added; _attr_class_item updated for multi-token toggling; re-exports updated
Tests New fixtures for CSS module class resolution and style-tag CSS module classes
Build artifacts Updated size files and bundle size comments
Docs New changeset describing the optimization

Sequence Diagram(s)

sequenceDiagram
  participant Template as Marko Template
  participant Analyzer as native-tag analyze
  participant CSSUtil as css-module-class util
  participant Codegen as class attr codegen
  participant Runtime as _attr_class_item / _class_module_value

  Template->>Analyzer: class=[styles.card, {[styles.active]: on}]
  Analyzer->>CSSUtil: isCSSModuleClass(expr, scope)
  CSSUtil-->>Analyzer: known CSS module member
  Analyzer->>Codegen: buildClassAttrExpression(meta, cssScope)
  Codegen->>Codegen: buildCSSModuleClassExpression
  Codegen-->>Runtime: apply constant string once
  Codegen-->>Runtime: update toggles with _attr_class_item
  Runtime->>Runtime: classList.toggle per token
Loading

Compact metadata
Estimated effort: Medium-High
Lines changed: approximately +415/-56 across 18 files

Related issues: None specified
Related PRs: None specified

Suggested labels: performance, runtime-tags, translator, needs-review

Suggested reviewers: Marko core maintainers familiar with translator codegen and runtime DOM helpers

Poem

A rabbit hopped through stylesheet maze,
Found styles.card in module haze,
Folded strings that never change,
Left the toggles free to range,
Now classes bloom in fewer ways. 🐰🎨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: optimizing class attributes built from CSS module classes.
Description check ✅ Passed The description is detailed and directly matches the changeset, covering the optimizer, DOM/runtime updates, and tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/marko-idle-review-feedback-7ri0ar

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/runtime-tags/src/dom/dom.ts (1)

90-106: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Guard _attr_class_item against missing CSS-module names

classModuleValue can pass undefined here in optimized builds, and name.indexOf(" ") now throws before any class is applied. Add a nullish guard before splitting/toggling so a typoed or renamed CSS-module export fails gracefully.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/runtime-tags/src/dom/dom.ts` around lines 90 - 106, The
_attr_class_item helper currently assumes name is always a string, so
classModuleValue can pass undefined and cause name.indexOf(" ") to throw before
any class updates happen. Add a nullish guard at the start of _attr_class_item
in dom.ts so missing CSS-module exports are ignored safely, and keep the
existing classList.toggle logic for valid names.
🧹 Nitpick comments (1)
packages/runtime-tags/src/translator/visitors/tag/native-tag.ts (1)

1478-1496: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Dev-mode CSS module warning is skipped for the simplest usage: class={styles.foo}.

buildClassAttrExpression bails immediately when value isn't an ObjectExpression/ArrayExpression (Line 1482-1484). This means a bare, unwrapped class value like class={styles.foo} (the most common CSS-module usage pattern) never reaches buildCSSModuleClassExpression, so it's emitted as a raw callRuntime(helper, branch) without the classModuleValue/_class_module_value dev-time validation wrapper that other shapes (arrays/objects) get. A typo'd class name here (e.g. styles.crad) will silently resolve to undefined at runtime with zero dev diagnostics, then surface only as the runtime crash described in the dom/dom.ts review of _attr_class_item.

Consider also wrapping the lone-expression case with classModuleValue when isCSSModuleClass(value, cssScope) is true, for consistent dev coverage.

♻️ Sketch of extending coverage to the lone-expression case
 function buildClassAttrExpression(
   value: t.Expression,
   cssScope: CSSModuleScope,
 ) {
+  if (isCSSModuleClass(value, cssScope)) {
+    return callRuntime("_attr_class", classModuleValue(value));
+  }
+
   if (value.type !== "ObjectExpression" && value.type !== "ArrayExpression") {
     return;
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/runtime-tags/src/translator/visitors/tag/native-tag.ts` around lines
1478 - 1496, The lone CSS-module class case is skipped in
buildClassAttrExpression, so class={styles.foo} never gets the dev-time
classModuleValue/_class_module_value validation wrapper. Update
buildClassAttrExpression in native-tag.ts to also handle a bare expression when
isCSSModuleClass(value, cssScope) is true, and route it through
buildCSSModuleClassExpression or the same wrapper logic used for object/array
shapes so simple CSS-module usage gets consistent diagnostics.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/runtime-tags/src/translator/core/style.ts`:
- Around line 95-102: The CSS-module registration in style translation is too
broad because registerStyleModuleVar(node.var.name) runs even when there is no
resolved import path. Update the analyze logic in style.ts so the var name is
only registered when importPath is truthy, and keep the check tied to the
existing node.var handling in analyze and the isCSSModuleClass lookup path. This
ensures only style tags that actually emit a backing import are treated as
CSS-module objects.

In `@packages/runtime-tags/src/translator/util/css-module-class.ts`:
- Around line 52-62: The `<style/var>` check in `css-module-class.ts` is
matching by identifier name alone, which can incorrectly fold shadowed or
unrelated bindings into CSS module output. Update the
`getProgram().node.extra?.styleModuleVars` flow to carry and compare the
analyzed binding identity in `cssModuleClass` rather than `expr.object.name`,
and keep the existing `scope.getBinding(...)` module-binding validation as the
fallback for translate-time matching.

---

Outside diff comments:
In `@packages/runtime-tags/src/dom/dom.ts`:
- Around line 90-106: The _attr_class_item helper currently assumes name is
always a string, so classModuleValue can pass undefined and cause name.indexOf("
") to throw before any class updates happen. Add a nullish guard at the start of
_attr_class_item in dom.ts so missing CSS-module exports are ignored safely, and
keep the existing classList.toggle logic for valid names.

---

Nitpick comments:
In `@packages/runtime-tags/src/translator/visitors/tag/native-tag.ts`:
- Around line 1478-1496: The lone CSS-module class case is skipped in
buildClassAttrExpression, so class={styles.foo} never gets the dev-time
classModuleValue/_class_module_value validation wrapper. Update
buildClassAttrExpression in native-tag.ts to also handle a bare expression when
isCSSModuleClass(value, cssScope) is true, and route it through
buildCSSModuleClassExpression or the same wrapper logic used for object/array
shapes so simple CSS-module usage gets consistent diagnostics.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2493c2ed-af98-4196-9c9a-fa8e7bc89be3

📥 Commits

Reviewing files that changed from the base of the PR and between 98ea020 and 55ed6b4.

⛔ Files ignored due to path filters (6)
  • packages/runtime-tags/src/__tests__/fixtures/attr-class-css-module-style/__snapshots__/dom.bundle.debug.js is excluded by !**/__snapshots__/** and included by **
  • packages/runtime-tags/src/__tests__/fixtures/attr-class-css-module-style/__snapshots__/html.bundle.debug.js is excluded by !**/__snapshots__/** and included by **
  • packages/runtime-tags/src/__tests__/fixtures/attr-class-css-module-style/__snapshots__/html.bundle.js is excluded by !**/__snapshots__/** and included by **
  • packages/runtime-tags/src/__tests__/fixtures/attr-class-css-module/__snapshots__/dom.bundle.debug.js is excluded by !**/__snapshots__/** and included by **
  • packages/runtime-tags/src/__tests__/fixtures/attr-class-css-module/__snapshots__/html.bundle.debug.js is excluded by !**/__snapshots__/** and included by **
  • packages/runtime-tags/src/__tests__/fixtures/attr-class-css-module/__snapshots__/html.bundle.js is excluded by !**/__snapshots__/** and included by **
📒 Files selected for processing (18)
  • .changeset/css-module-class-optimization.md
  • .sizes.json
  • .sizes/dom.js
  • packages/runtime-tags/src/__tests__/fixtures/attr-class-css-module-style/sizes.json
  • packages/runtime-tags/src/__tests__/fixtures/attr-class-css-module-style/template.marko
  • packages/runtime-tags/src/__tests__/fixtures/attr-class-css-module-style/test.ts
  • packages/runtime-tags/src/__tests__/fixtures/attr-class-css-module/sizes.json
  • packages/runtime-tags/src/__tests__/fixtures/attr-class-css-module/styles.module.css
  • packages/runtime-tags/src/__tests__/fixtures/attr-class-css-module/template.marko
  • packages/runtime-tags/src/__tests__/fixtures/attr-class-css-module/test.ts
  • packages/runtime-tags/src/__tests__/fixtures/define-tag-for-attribute-tag/sizes.json
  • packages/runtime-tags/src/common/helpers.ts
  • packages/runtime-tags/src/dom.ts
  • packages/runtime-tags/src/dom/dom.ts
  • packages/runtime-tags/src/html.ts
  • packages/runtime-tags/src/translator/core/style.ts
  • packages/runtime-tags/src/translator/util/css-module-class.ts
  • packages/runtime-tags/src/translator/visitors/tag/native-tag.ts

Comment on lines +95 to +102
// Record the `var` name (eg `<style/styles>`) so the `class` attribute
// optimizer recognizes `styles.foo` as a CSS module class. Done in analyze
// because translate removes this tag and emits the backing import late,
// after which the name is no longer resolvable as a binding.
if (t.isIdentifier(node.var)) {
registerStyleModuleVar(node.var.name);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect getStyleImportPath to see when it can be falsy while node.var is set.
rg -n "function getStyleImportPath" -A 40 packages/runtime-tags/src/translator

Repository: marko-js/marko

Length of output: 3536


🏁 Script executed:

#!/bin/bash
sed -n '1,180p' packages/runtime-tags/src/translator/core/style.ts
printf '\n---\n'
sed -n '180,340p' packages/runtime-tags/src/translator/core/style.ts
printf '\n---\n'
sed -n '340,460p' packages/runtime-tags/src/translator/core/style.ts

Repository: marko-js/marko

Length of output: 11881


🏁 Script executed:

#!/bin/bash
printf '%s\n' '--- css-module-class ---'
sed -n '1,220p' packages/runtime-tags/src/translator/util/css-module-class.ts
printf '\n%s\n' '--- usages ---'
rg -n "registerStyleModuleVar|isCSSModuleClass" packages/runtime-tags/src/translator -A 3 -B 3
printf '\n%s\n' '--- style import path callers ---'
rg -n "styleImportPath" packages/runtime-tags/src/translator -A 3 -B 3

Repository: marko-js/marko

Length of output: 11712


🏁 Script executed:

#!/bin/bash
set -e
sed -n '1,220p' packages/runtime-tags/src/translator/util/css-module-class.ts
printf '\n---\n'
rg -n "registerStyleModuleVar|isCSSModuleClass" packages/runtime-tags/src/translator -A 4 -B 4

Repository: marko-js/marko

Length of output: 11545


Guard CSS-module registration on a resolved import path
registerStyleModuleVar(node.var.name) should only run when importPath is truthy. isCSSModuleClass() treats any registered <style var> as a CSS-module object, so a style tag with var but no emitted import can make unrelated styles.foo accesses look static.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/runtime-tags/src/translator/core/style.ts` around lines 95 - 102,
The CSS-module registration in style translation is too broad because
registerStyleModuleVar(node.var.name) runs even when there is no resolved import
path. Update the analyze logic in style.ts so the var name is only registered
when importPath is truthy, and keep the check tied to the existing node.var
handling in analyze and the isCSSModuleClass lookup path. This ensures only
style tags that actually emit a backing import are treated as CSS-module
objects.

Comment thread packages/runtime-tags/src/translator/util/css-module-class.ts
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.

1 participant