Skip to content
Merged
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
13 changes: 13 additions & 0 deletions .changepacks/changepack_log_deterministic_numbering.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"changes": {
"bindings/devup-ui-wasm/package.json": "Patch",
"packages/plugin-utils/package.json": "Patch",
"packages/vite-plugin/package.json": "Patch",
"packages/webpack-plugin/package.json": "Patch",
"packages/rsbuild-plugin/package.json": "Patch",
"packages/next-plugin/package.json": "Patch",
"packages/bun-plugin/package.json": "Patch"
},
"note": "Class names and file numbers no longer depend on the order files are processed in. Every plugin walks the files it can extract (the project's source and the included packages) before the first transform and numbers them in path order (a file collapsed into a bucket is numbered as the bucket, and only files that mention the package, StyleX or an aliased package take numbers) through the new seedFileMap, so identical builds give identical class prefixes, CSS file names and output hashes; in development, files that appear later are numbered after the existing ones. Styles are given class names in a fixed order, not the order of a hash set. The exported class, file and canonical maps are canonical JSON (sorted keys). Stylesheet evaluation (vanilla-extract) refuses Math.random(), Date.now(), new Date() and Date(), which differ between builds, with a build error; fixed dates such as new Date(0) are unchanged. resetBuildState starts a build from its own options: the prefix, atom hoisting, routes, buckets, names, numbers and styles an earlier build in the process left are cleared, and the prefix is set on every setup even when it is absent. Engine snapshots from concurrent transforms are written one after another per file, so an older snapshot is never written last. File lists are ordered by code point rather than the locale",
"date": "2026-10-01T00:00:00.000Z"
}
7 changes: 3 additions & 4 deletions apps/landing/src/app/(detail)/docs/build-errors/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1902,15 +1902,14 @@ export function f() { const c = css; c = () => 1; return c({ color: "red" }); }

**Why:** A followed alias cannot be reassigned after the rewrite removes its declaration.

**Fix:** Keep the const alias unchanged and call it.
**Fix:** Call the imported css directly instead of creating a local runtime alias.

{/* probe: mutated-const-alias after */}

```tsx
import { css } from '@devup-ui/react'
export function f() {
const c = css
return c({ color: 'red' })
return css({ color: 'red' })
}
```

Expand Down Expand Up @@ -3926,7 +3925,7 @@ The `devup/no-runtime-read` rule reports compile-time bindings read outside supp
`<binding>` is read at runtime, where it does not exist: the build compiles it only where it is called or rendered.
```

```tsx
```tsx-error
import { css } from '@devup-ui/react'
consume(css)
```
Expand Down
103 changes: 100 additions & 3 deletions bindings/devup-ui-wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,14 @@ pub fn export_sheet() -> Result<String, JsValue> {

/// Internal function to export class map as JSON string (testable without `JsValue`)
pub fn export_class_map_internal() -> Result<String, String> {
with_class_map(serde_json::to_string).map_err(|e| e.to_string())
with_class_map(|map| {
let sorted: BTreeMap<&String, BTreeMap<&String, &usize>> = map
.iter()
.map(|(file, classes)| (file, classes.iter().collect()))
.collect();
serde_json::to_string(&sorted)
})
.map_err(|e| e.to_string())
}

#[wasm_bindgen(js_name = "importClassMap")]
Expand All @@ -261,7 +268,11 @@ pub fn export_class_map() -> Result<String, JsValue> {

/// Internal function to export file map as JSON string (testable without `JsValue`)
pub fn export_file_map_internal() -> Result<String, String> {
with_file_map(serde_json::to_string).map_err(|e| e.to_string())
with_file_map(|map| {
let sorted: BTreeMap<&String, &usize> = map.iter().collect();
serde_json::to_string(&sorted)
})
.map_err(|e| e.to_string())
}

#[wasm_bindgen(js_name = "importFileMap")]
Expand All @@ -284,7 +295,11 @@ pub fn import_canonical_map_internal(map: HashMap<String, String>) {

/// Internal function to export the canonical map as JSON string (testable without `JsValue`)
pub fn export_canonical_map_internal() -> Result<String, String> {
with_canonical_map(serde_json::to_string).map_err(|e| e.to_string())
with_canonical_map(|map| {
let sorted: BTreeMap<&String, &String> = map.iter().collect();
serde_json::to_string(&sorted)
})
.map_err(|e| e.to_string())
}

#[wasm_bindgen(js_name = "importCanonicalMap")]
Expand All @@ -300,6 +315,34 @@ pub fn export_canonical_map() -> Result<String, JsValue> {
export_canonical_map_internal().map_err(js_error)
}

/// Number every file in `files` now, in path order, keeping numbers files
/// already hold, so class prefixes depend on the paths and not on the order
/// workers reach files in.
#[wasm_bindgen(js_name = "seedFileMap")]
pub fn seed_file_map(files: Vec<String>) {
css::file_map::seed_file_numbers(&files);
}

/// Forget everything one build left in the engine.
///
/// That is names, numbers, styles, buckets, routes, the prefix, atom hoisting
/// and the module resolver, so the next build starts from its own options
/// alone. Theme, shorthands and debug mode are set by every build, and stay.
pub fn reset_build_state_internal() {
css::class_map::reset_class_map();
css::file_map::reset_file_map();
css::file_map::reset_canonical_map();
css::file_routes::set_file_routes(HashMap::new());
css::atom_hoist::set_atom_hoist(None);
css::set_prefix(None);
with_style_sheet_mut(|sheet| *sheet = StyleSheet::default());
MODULE_RESOLVER.with_borrow_mut(|current| *current = None);
}

#[wasm_bindgen(js_name = "resetBuildState")]
pub fn reset_build_state() {
reset_build_state_internal();
}
/// Set the atom-level hoist threshold.
///
/// When set to `Some(n)`, a style atom whose content is used by `>= n` distinct
Expand Down Expand Up @@ -1929,6 +1972,60 @@ mod tests {
assert!(json_str.starts_with('{') || json_str.starts_with("[]"));
}

#[test]
#[serial]
fn test_exported_maps_are_canonical_json() {
css::class_map::reset_class_map();
css::file_map::reset_file_map();
css::file_map::reset_canonical_map();
css::class_map::set_class_map(HashMap::from([
(
"b.tsx".to_string(),
HashMap::from([("z".to_string(), 1), ("a".to_string(), 0)]),
),
("a.tsx".to_string(), HashMap::from([("k".to_string(), 0)])),
]));
assert_eq!(
export_class_map_internal().unwrap(),
r#"{"a.tsx":{"k":0},"b.tsx":{"a":0,"z":1}}"#
);
seed_file_map(vec!["b.tsx".to_string(), "a.tsx".to_string()]);
assert_eq!(
export_file_map_internal().unwrap(),
r#"{"a.tsx":0,"b.tsx":1}"#
);
import_canonical_map_internal(HashMap::from([
("y".to_string(), "b".to_string()),
("x".to_string(), "a".to_string()),
]));
assert_eq!(
export_canonical_map_internal().unwrap(),
r#"{"x":"a","y":"b"}"#
);
reset_build_state();
assert_eq!(export_class_map_internal().unwrap(), "{}");
assert_eq!(export_file_map_internal().unwrap(), "{}");
assert_eq!(export_canonical_map_internal().unwrap(), "{}");
assert_eq!(get_prefix(), None);
}

#[test]
#[serial]
fn test_numbers_do_not_depend_on_the_order_files_are_seen() {
let files = ["src/b.tsx", "src/a.tsx", "src/c.tsx"];
let mut maps = Vec::new();
for order in [[0, 1, 2], [2, 1, 0], [1, 2, 0]] {
reset_build_state_internal();
seed_file_map(files.iter().map(ToString::to_string).collect());
for index in order {
let _ = css::file_map::get_file_num_by_filename(files[index]);
}
maps.push(export_file_map_internal().unwrap());
}
assert_eq!(maps[0], r#"{"src/a.tsx":0,"src/b.tsx":1,"src/c.tsx":2}"#);
assert_eq!(maps[0], maps[1]);
assert_eq!(maps[0], maps[2]);
}
#[test]
#[serial]
fn test_code_extract_internal_success() {
Expand Down
7 changes: 4 additions & 3 deletions e2e/tailwind-hover-order.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ for (const singleCss of [true, false]) {
for (const fixture of cases) {
for (const [index, width] of [640, 1024].entries()) {
const label = 'props' in fixture ? 'explicit Devup guard: ' : ''
const devupColorIndex = 'props' in fixture ? 1 : 0
test(`${label}${fixture.classes}, ${width}px, singleCss=${singleCss}`, async ({
page,
}, testInfo) => {
Expand Down Expand Up @@ -191,7 +192,7 @@ for (const singleCss of [true, false]) {
const observed = []
for (const [name, classes, css, color] of [
['Tailwind', fixture.classes, oracleCss, expected[0]],
['Devup', matched?.[1], devupCss, expected[1]],
['Devup', matched?.[1], devupCss, expected[devupColorIndex]],
]) {
await page.setContent(
`<!doctype html><html data-theme="dark"><head><style>${css}</style></head><body><button id="target" class="${classes}" ${disabled ? 'disabled' : ''} style="width:160px;height:80px">probe</button></body></html>`,
Expand All @@ -207,14 +208,14 @@ for (const singleCss of [true, false]) {
} else if (!disabled) {
await expect(target).toHaveCSS(
'background-color',
colors.red[name === 'Tailwind' ? 0 : 1],
colors.red[name === 'Tailwind' ? 0 : devupColorIndex],
)
}
if (fixture.classes.includes('focus:')) {
await target.focus()
await expect(target).toHaveCSS(
'background-color',
colors.green[name === 'Tailwind' ? 0 : 1],
colors.green[name === 'Tailwind' ? 0 : devupColorIndex],
)
}
await page.mouse.down()
Expand Down
54 changes: 54 additions & 0 deletions libs/css/src/file_map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,19 @@ pub fn get_file_num_by_filename(filename: &str) -> usize {
})
}

/// Give every file in `files` a number now, in path order.
///
/// Numbers files already hold are kept, and a file collapsed into a bucket is numbered as the bucket. The numbers files get then depend on the paths alone,
/// not on which file a worker reaches first; files that appear later (in
/// development) are numbered after the existing ones.
pub fn seed_file_numbers(files: &[String]) {
let mut sorted: Vec<String> = files.iter().map(|file| canonical(file)).collect();
sorted.sort_unstable();
sorted.dedup();
for file in sorted {
let _ = get_file_num_by_filename(&file);
}
}
#[must_use]
pub fn get_filename_by_file_num(file_num: usize) -> String {
with_file_map(|map| {
Expand Down Expand Up @@ -196,6 +209,47 @@ mod tests {

use super::*;

#[test]
#[serial]
fn test_seed_file_numbers_numbers_buckets_not_their_members() {
reset_file_map();
set_canonical_map(std::collections::HashMap::from([(
"x.tsx".to_string(),
"a.tsx".to_string(),
)]));
seed_file_numbers(&[
"x.tsx".to_string(),
"b.tsx".to_string(),
"a.tsx".to_string(),
]);
assert_eq!(get_file_num_by_filename("a.tsx"), 0);
assert_eq!(get_file_num_by_filename("b.tsx"), 1);
assert_eq!(get_file_map().len(), 2);
reset_canonical_map();
reset_file_map();
}

#[test]
#[serial]
fn test_seed_file_numbers_is_independent_of_order() {
reset_file_map();
seed_file_numbers(&[
"b.tsx".to_string(),
"a.tsx".to_string(),
"b.tsx".to_string(),
]);
assert_eq!(get_file_num_by_filename("a.tsx"), 0);
assert_eq!(get_file_num_by_filename("b.tsx"), 1);
seed_file_numbers(&[
"c.tsx".to_string(),
"0.tsx".to_string(),
"a.tsx".to_string(),
]);
assert_eq!(get_file_num_by_filename("a.tsx"), 0);
assert_eq!(get_file_num_by_filename("0.tsx"), 2);
assert_eq!(get_file_num_by_filename("c.tsx"), 3);
reset_file_map();
}
#[test]
#[serial]
fn test_set_and_get_file_map() {
Expand Down
24 changes: 24 additions & 0 deletions libs/extractor/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13716,6 +13716,30 @@ globalCss({
assert!(!output.code.contains("css("), "{}", output.code);
}

#[test]
#[serial]
fn test_stylesheets_read_nothing_that_differs_between_builds() {
for (read, fails) in [
("Math.random()", true),
("Date.now()", true),
("new Date()", true),
("Date()", true),
("new Date(0).getTime()", false),
("Date.UTC(2020, 0, 1)", false),
] {
reset_class_map();
reset_file_map();
let result = extract(
"when.css.ts",
&format!(
"import {{ style }} from '@devup-ui/react';\nconst n = {read};\nexport const a = style({{ opacity: String(n) }});"
),
ExtractOption::default(),
);
assert_eq!(result.is_err(), fails, "{read}");
}
}

#[test]
#[serial]
fn test_vanilla_extract_execution_fallback() {
Expand Down
15 changes: 15 additions & 0 deletions libs/extractor/src/module_loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,21 @@ pub(crate) const PACKAGE_BINDING: &str = "__vanilla_extract__";

/// A console whose calls do nothing, as what a module logs while it runs
/// changes no value
/// Run before a stylesheet: what differs between builds fails the evaluation,
/// so a stylesheet never gives different rules for the same source. `new
/// Date(0)` and other fixed dates stay.
pub(crate) const DETERMINISM: &str = r#"(() => {
const refuse = (name) => () => { throw new Error(`${name} gives a different value each build: a stylesheet cannot read it`); };
Math.random = refuse("Math.random");
const RealDate = Date;
globalThis.Date = new Proxy(RealDate, {
construct: (target, args, newTarget) => args.length === 0 ? refuse("new Date()")() : Reflect.construct(target, args, newTarget),
apply: refuse("Date()"),
get: (target, key, receiver) => key === "now" ? refuse("Date.now") : Reflect.get(target, key, receiver),
});
})();
"#;

pub(crate) const CONSOLE: &str = "if (typeof console === \"undefined\") globalThis.console = new Proxy({}, { get: () => () => undefined });\n";

/// Loop iterations an evaluation may run before it fails instead of hanging
Expand Down
6 changes: 6 additions & 0 deletions libs/extractor/src/tailwind_children.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ pub fn children_utility(name: &str) -> Option<Vec<Declaration>> {
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use rstest::rstest;
use serial_test::serial;

use super::*;
use crate::tailwind::{declarations_of, parse_class};
Expand Down Expand Up @@ -177,6 +178,7 @@ mod tests {
])]
#[case("space-x-reverse", &[("--tw-space-x-reverse", "1")])]
#[case("space-y-reverse", &[("--tw-space-y-reverse", "1")])]
#[serial]
fn space_adds_margins_between_the_children(
#[case] class: &str,
#[case] expected: &[(&str, &str)],
Expand Down Expand Up @@ -229,6 +231,7 @@ mod tests {
}

#[test]
#[serial]
fn divide_reverse_style_and_color() {
expect("divide-x-reverse", &[("--tw-divide-x-reverse", "1")]);
expect("divide-y-reverse", &[("--tw-divide-y-reverse", "1")]);
Expand Down Expand Up @@ -268,11 +271,13 @@ mod tests {
#[case("space-x-huge")]
#[case("-space-x-reverse")]
#[case("space-z-4")]
#[serial]
fn unknown_children_utilities_stay_as_written(#[case] class: &str) {
assert_eq!(declarations_of(class), None, "{class}");
}

#[test]
#[serial]
fn children_utilities_apply_under_variants_to_the_children() {
let class = parse_class("space-x-4").unwrap();
assert_eq!(class.conditions.len(), 1);
Expand Down Expand Up @@ -310,6 +315,7 @@ mod tests {
}

#[test]
#[serial]
fn children_rules_register_their_variables() {
assert_eq!(
parse_class("space-x-4").unwrap().rules(),
Expand Down
Loading
Loading