Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/css-module-class-optimization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@marko/runtime-tags": patch
---

Optimize `class` attributes built from CSS module classes. A member access on a CSS module - a default/namespace import from a stylesheet, or a `<style/name>` tag's object - is now treated as a known class string, so `class=[styles.card, { [styles.active]: on }]` no longer falls back to the runtime `class` walker. Constant classes are folded into the concatenated string on the server and applied once at mount on the client, while toggles update in place. `_attr_class_item` now also handles a class that resolves to multiple space-separated tokens (eg from `composes:`). In development, a CSS module class that resolves to a non-string (a typo or a class missing from the stylesheet) logs a warning; this assertion is compiled away in optimized builds.
4 changes: 2 additions & 2 deletions .sizes.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
{
"name": "*",
"total": {
"min": 25867,
"brotli": 9506
"min": 25968,
"brotli": 9546
}
},
{
Expand Down
10 changes: 8 additions & 2 deletions .sizes/dom.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// size: 25867 (min) 9506 (brotli)
// size: 25968 (min) 9546 (brotli)
//#region packages/runtime-tags/dist/dom.mjs
let empty = [],
rest = Symbol(),
Expand Down Expand Up @@ -327,6 +327,9 @@ function _call(fn, v) {
function stringifyClassObject(name, value) {
return value ? name : "";
}
function _class_module_value(value, name) {
return value;
}
function stringifyStyleObject(name, value) {
return value || value === 0 ? name + ":" + value : "";
}
Expand Down Expand Up @@ -1449,7 +1452,10 @@ function _attr_class_items(element, items) {
for (let key in items) _attr_class_item(element, key, items[key]);
}
function _attr_class_item(element, name, value) {
element.classList.toggle(name, !!value);
let { classList } = element;
if (~name.indexOf(" "))
for (let token of name.split(" ")) classList.toggle(token, !!value);
else classList.toggle(name, !!value);
}
function _attr_style(element, value) {
setAttribute(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// template.marko
const $template = "<div></div><div></div>";
const $walks = " b b";
function $setup($scope) {
_attr_class_item($scope["#div/0"], _class_module_value(void 0, "card"), 1);
_attr_class_item($scope["#div/1"], _class_module_value(void 0, "card"), 1);
}
const $active = /* @__PURE__ */ _const("active", ($scope) => {
_attr_class_item($scope["#div/0"], _class_module_value(void 0, "on"), $scope.active);
_attr_class_item($scope["#div/1"], _class_module_value(void 0, "on"), $scope.active);
});
const $input = ($scope, input) => $active($scope, input.active);
var template_default = /* @__PURE__ */ _template("__tests__/template.marko", $template, $walks, $setup, $input);

// v:template.marko.module.css
var v_template_marko_module_default = "\n .card { color: green }\n .on { color: blue }\n";
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// v:template.marko.module.css
var v_template_marko_module_default = "\n .card { color: green }\n .on { color: blue }\n";

// template.marko
var template_default = _template("__tests__/template.marko", (input) => {
const $scope0_reason = _scope_reason(), $sg__input_active = _serialize_guard($scope0_reason, 0);
const $scope0_id = _scope_id();
const { active } = input;
_html(`<div${_attr_class(`${_class_module_value(void 0, "card")}${active ? " " + _class_module_value(void 0, "on") : ""}`)}></div>${_el_resume($scope0_id, "#div/0", $sg__input_active)}<div${_attr_class(`${_class_module_value(void 0, "card")}${active ? " " + _class_module_value(void 0, "on") : ""}`)}></div>${_el_resume($scope0_id, "#div/1", $sg__input_active)}`);
_serialize_if($scope0_reason, 0) && writeScope($scope0_id, {}, "__tests__/template.marko", 0);
}, 1);
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// template.marko
var template_default = _template("a", (input) => {
const $scope0_reason = _scope_reason(), $sg__input_active = _serialize_guard($scope0_reason, 0);
const $scope0_id = _scope_id();
const { active } = input;
_html(`<div${_attr_class(`${void 0}${active ? " " + void 0 : ""}`)}></div>${_el_resume($scope0_id, "a", $sg__input_active)}<div${_attr_class(`${void 0}${active ? " " + void 0 : ""}`)}></div>${_el_resume($scope0_id, "b", $sg__input_active)}`);
_serialize_if($scope0_reason, 0) && writeScope($scope0_id, {});
}, 1);
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"dom": {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<style/styles>
.card { color: green }
.on { color: blue }
</style>

<const/{ active }=input/>

// A `<style/var>` object resolves classes the same way a module import does.
<div class=[styles.card, { [styles.on]: active }]/>
<div class=[styles.card, active && styles.on]/>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { TestConfig } from "../../main.test";

// CSS module class values are supplied by the bundler, not this harness (which
// loads `.css` as text), so this fixture asserts on the compiled output only.
export const config: TestConfig = {
skip_csr: true,
skip_ssr: true,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// template.marko
const $template = "<div></div><div></div><div></div><div></div><div class=base></div>";
const $walks = " b b b b b";
function $setup($scope) {
_attr_class_item($scope["#div/0"], _class_module_value(styles_module_default.card, "card"), 1);
_attr_class_item($scope["#div/3"], _class_module_value(styles_module_default.card, "card"), 1);
_attr_class_item($scope["#div/4"], _class_module_value(styles_module_default.card, "card"), 1);
}
const $active = /* @__PURE__ */ _const("active", ($scope) => {
_attr_class_item($scope["#div/0"], _class_module_value(styles_module_default.on, "on"), $scope.active);
_attr_class_item($scope["#div/1"], _class_module_value(styles_module_default.on, "on"), $scope.active);
_attr_class_item($scope["#div/3"], _class_module_value(styles_module_default.on, "on"), $scope.active);
_attr_class_item($scope["#div/4"], _class_module_value(styles_module_default.on, "on"), $scope.active);
});
const $input_x__OR__input_y = /* @__PURE__ */ _or(10, ($scope) => _attr_class_items($scope["#div/2"], {
[_class_module_value(styles_module_default.a, "a")]: $scope.x,
[_class_module_value(styles_module_default.b, "b")]: $scope.y
}));
const $x = /* @__PURE__ */ _const("x", $input_x__OR__input_y);
const $y = /* @__PURE__ */ _const("y", $input_x__OR__input_y);
const $input = ($scope, input) => {
$active($scope, input.active);
$x($scope, input.x);
$y($scope, input.y);
};
var template_default = /* @__PURE__ */ _template("__tests__/template.marko", $template, $walks, $setup, $input);

// styles.module.css
var styles_module_default = ".card {\n color: green;\n}\n.on {\n color: blue;\n}\n.a {\n color: red;\n}\n.b {\n color: teal;\n}\n";
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// styles.module.css
var styles_module_default = ".card {\n color: green;\n}\n.on {\n color: blue;\n}\n.a {\n color: red;\n}\n.b {\n color: teal;\n}\n";

// template.marko
var template_default = _template("__tests__/template.marko", (input) => {
const $scope0_reason = _scope_reason(), $sg__input_active = _serialize_guard($scope0_reason, 2);
const $scope0_id = _scope_id();
const { active, x, y } = input;
_html(`<div${_attr_class(`${_class_module_value(styles_module_default.card, "card")}${active ? " " + _class_module_value(styles_module_default.on, "on") : ""}`)}></div>${_el_resume($scope0_id, "#div/0", $sg__input_active)}<div${_attr_class(active ? _class_module_value(styles_module_default.on, "on") : "")}></div>${_el_resume($scope0_id, "#div/1", $sg__input_active)}<div${_attr_class([x && _class_module_value(styles_module_default.a, "a"), y && _class_module_value(styles_module_default.b, "b")])}></div>${_el_resume($scope0_id, "#div/2", _serialize_guard($scope0_reason, 0))}<div${_attr_class(`${_class_module_value(styles_module_default.card, "card")}${active ? " " + _class_module_value(styles_module_default.on, "on") : ""}`)}></div>${_el_resume($scope0_id, "#div/3", $sg__input_active)}<div${_attr_class(`base ${_class_module_value(styles_module_default.card, "card")}${active ? " " + _class_module_value(styles_module_default.on, "on") : ""}`)}></div>${_el_resume($scope0_id, "#div/4", $sg__input_active)}`);
_serialize_if($scope0_reason, 1) && writeScope($scope0_id, {
x: _serialize_if($scope0_reason, 4) && x,
y: _serialize_if($scope0_reason, 3) && y
}, "__tests__/template.marko", 0, {
x: "3:18",
y: "3:21"
});
}, 1);
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// styles.module.css
var styles_module_default = ".card {\n color: green;\n}\n.on {\n color: blue;\n}\n.a {\n color: red;\n}\n.b {\n color: teal;\n}\n";

// template.marko
var template_default = _template("a", (input) => {
const $scope0_reason = _scope_reason(), $sg__input_active = _serialize_guard($scope0_reason, 2);
const $scope0_id = _scope_id();
const { active, x, y } = input;
_html(`<div${_attr_class(`${styles_module_default.card}${active ? " " + styles_module_default.on : ""}`)}></div>${_el_resume($scope0_id, "a", $sg__input_active)}<div${_attr_class(active ? styles_module_default.on : "")}></div>${_el_resume($scope0_id, "b", $sg__input_active)}<div${_attr_class([x && styles_module_default.a, y && styles_module_default.b])}></div>${_el_resume($scope0_id, "c", _serialize_guard($scope0_reason, 0))}<div${_attr_class(`${styles_module_default.card}${active ? " " + styles_module_default.on : ""}`)}></div>${_el_resume($scope0_id, "d", $sg__input_active)}<div${_attr_class(`base ${styles_module_default.card}${active ? " " + styles_module_default.on : ""}`)}></div>${_el_resume($scope0_id, "e", $sg__input_active)}`);
_serialize_if($scope0_reason, 1) && writeScope($scope0_id, {
i: _serialize_if($scope0_reason, 4) && x,
j: _serialize_if($scope0_reason, 3) && y
});
}, 1);
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"dom": {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.card {
color: green;
}
.on {
color: blue;
}
.a {
color: red;
}
.b {
color: teal;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import styles from "./styles.module.css";

<const/{ active, x, y }=input/>

// always-applied member + toggle
<div class=[styles.card, { [styles.on]: active }]/>
// single toggle
<div class={ [styles.on]: active }/>
// pure multi-toggle
<div class={ [styles.a]: x, [styles.b]: y }/>
// array-boolean idiom
<div class=[styles.card, active && styles.on]/>
// static literal mixed with a css module class
<div class=["base", styles.card, { [styles.on]: active }]/>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { TestConfig } from "../../main.test";

// CSS module class values are supplied by the bundler, not this harness (which
// loads `.css` as text), so this fixture asserts on the compiled output only.
export const config: TestConfig = {
skip_csr: true,
skip_ssr: true,
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"dom": {
"template.marko.page.mjs": {
"total": {
"min": 13430,
"brotli": 5130
"min": 13505,
"brotli": 5152
},
"files": {
"tags/child.marko": 413,
Expand Down
18 changes: 18 additions & 0 deletions packages/runtime-tags/src/common/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,24 @@ export function stringifyClassObject(name: string, value: unknown) {
return value ? name : "";
}

// Dev-only guard the translator wraps around CSS module class accesses (eg
// `styles.foo`) in the optimized `class` attribute paths. A typo resolves to
// `undefined`, which would otherwise fold silently into the class string; here
// it surfaces as a warning. Compiled away in optimized builds (the translator
// emits the bare access), so this is never on the production hot path.
export function _class_module_value(value: unknown, name?: string) {
if (MARKO_DEBUG && typeof value !== "string") {
console.warn(
`Expected the CSS module class ${
name ? `\`${name}\`` : "access"
} to resolve to a string, but received ${
value === undefined ? "undefined" : `a ${typeof value}`
}. It may be a typo or missing from the stylesheet.`,
);
}
return value;
}

export function stringifyStyleObject(name: string, value: unknown) {
return value || value === 0 ? name + ":" + value : "";
}
Expand Down
2 changes: 1 addition & 1 deletion packages/runtime-tags/src/dom.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export { attrTag, attrTags } from "./common/attr-tag";
export { _assert_hoist } from "./common/errors";
export { forIn, forOf, forTo, forUntil } from "./common/for";
export { _call } from "./common/helpers";
export { _call, _class_module_value } from "./common/helpers";
export { $signal, $signalReset } from "./dom/abort-signal";
export { compat } from "./dom/compat";
export {
Expand Down
9 changes: 8 additions & 1 deletion packages/runtime-tags/src/dom/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,14 @@ export function _attr_class_item(
name: string,
value: unknown,
) {
element.classList.toggle(name, !!value);
const { classList } = element;
// Only pay for a split when a CSS module class resolves to multiple tokens
// (eg `composes:`), which `classList.toggle` would reject as a single token.
if (~name.indexOf(" ")) {
for (const token of name.split(" ")) classList.toggle(token, !!value);
} else {
classList.toggle(name, !!value);
}
}

export function _attr_style(element: Element, value: unknown) {
Expand Down
1 change: 1 addition & 0 deletions packages/runtime-tags/src/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export {
_el_read_error,
_hoist_read_error,
} from "./common/errors";
export { _class_module_value } from "./common/helpers";
export { _flush_head, withLoadAssets, withPageAssets } from "./html/assets";
export {
_attr,
Expand Down
9 changes: 9 additions & 0 deletions packages/runtime-tags/src/translator/core/style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import path from "path";

import { WalkCode } from "../../common/types";
import { addAssetImport } from "../util/asset-imports";
import { registerStyleModuleVar } from "../util/css-module-class";
import { isCoreTagName } from "../util/is-core-tag";
import { isOutputDOM } from "../util/marko-config";
import normalizeStringExpression from "../util/normalize-string-expression";
Expand Down Expand Up @@ -91,6 +92,14 @@ export default {
addAssetImport(file, importPath);
}

// 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);
}

if (names) {
analyzeDynamicStyle(tag, names);
// Dynamic styles write their shell statement in setup.
Expand Down
85 changes: 85 additions & 0 deletions packages/runtime-tags/src/translator/util/css-module-class.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { types as t } from "@marko/compiler";
import { getProgram } from "@marko/compiler/babel-utils";

// A member access whose object is a CSS module resolves to a class string at
// runtime (the bundler fills in the value). We can't know the string, but
// knowing it *is* a string lets the `class` attribute optimizer treat it like a
// static token instead of an opaque value handed to the runtime walker.
const CSS_MODULE_SOURCE_REG = /\.(css|less|s[ac]ss|styl|stylus|p?css)($|\?)/i;

declare module "@marko/compiler/dist/types" {
export interface NodeExtra {
styleModuleVars?: Set<string>;
}
}

/**
* Records that a `<style/name>` tag introduces `name` as a CSS module object.
* Called from the `<style>` tag's analyze so the name is known before any
* translate runs (translate removes the tag and emits the backing import, at
* which point the binding is no longer inspectable).
*/
export function registerStyleModuleVar(name: string) {
const program = getProgram().node;
((program.extra ??= {}).styleModuleVars ??= new Set()).add(name);
}

/**
* True when `expr` is `obj.prop` / `obj["prop"]` with a statically known key and
* `obj` is a CSS module: either a default/namespace import from a stylesheet
* source, or the `var` of a `<style>` tag (which compiles to such an import).
*/
export function isCSSModuleClass(
expr: t.Expression | t.PrivateName,
scope: {
getBinding(name: string): { kind: string; path: t.NodePath } | void;
},
): expr is t.MemberExpression {
if (!t.isMemberExpression(expr) || !t.isIdentifier(expr.object)) {
return false;
}

// Require a statically known property (`styles.foo` / `styles["foo"]`) so the
// access is a constant class string with no reactive dependency of its own.
if (
expr.computed
? !t.isStringLiteral(expr.property)
: !t.isIdentifier(expr.property)
) {
return false;
}

// A `<style/var>` matches by name in both phases. Checked before the binding
// because analyze still sees the tag's local binding while translate (which
// has removed the tag) sees none - relying on the binding would disagree.
if (getProgram().node.extra?.styleModuleVars?.has(expr.object.name)) {
return true;
}

const binding = scope.getBinding(expr.object.name);
if (!binding || binding.kind !== "module") {
return false;
}

const specifier = binding.path.node;
const decl = binding.path.parent;
return (
t.isImportDeclaration(decl) &&
(t.isImportDefaultSpecifier(specifier) ||
t.isImportNamespaceSpecifier(specifier)) &&
CSS_MODULE_SOURCE_REG.test(decl.source.value)
);
}

/** The accessed class name, when statically known, for dev diagnostics. */
export function cssModuleClassName(
expr: t.MemberExpression,
): string | undefined {
if (!expr.computed && t.isIdentifier(expr.property)) {
return expr.property.name;
}
if (expr.computed && t.isStringLiteral(expr.property)) {
return expr.property.value;
}
return undefined;
}
Loading
Loading