Skip to content

fix(plugin_css): handle empty 'url()' and improve performance#2265

Open
Nirvana-Jie wants to merge 1 commit intomainfrom
fix/css-url-resolver-improvements
Open

fix(plugin_css): handle empty 'url()' and improve performance#2265
Nirvana-Jie wants to merge 1 commit intomainfrom
fix/css-url-resolver-improvements

Conversation

@Nirvana-Jie
Copy link
Copy Markdown
Contributor

@Nirvana-Jie Nirvana-Jie commented Nov 27, 2025

📋 Description

This PR improves the CSS URL resolver in plugin_css by fixing empty URL handling and optimizing resource lookup performance.

🐛 Bug Fixes

  • Handle empty url() gracefully: Previously, empty URLs could cause processing errors. Now returns None early to skip invalid URLs.
  • Remove panic risk: Replaced unwrap() calls with safe Option handling. Missing resources now return None instead of panicking.

⚡ Performance Improvements

Before

  • O(n) linear search through all resources for each url() reference
  • Repeated computation of normalized_public_path for every URL

After

  • O(1) HashMap lookup using pre-built reverse resource map (ModuleIdresource_name)
  • Pre-computed normalized_public_path in constructor

Summary by CodeRabbit

  • Refactor

    • Unified CSS URL resolution and introduced normalized public-path handling for consistent asset rewriting.
  • Bug Fixes

    • Ignore empty/whitespace url() values and reliably select the designated primary output when multiple resources exist.
  • New Features

    • Added explicit primary marking for emitted resources to control which output is used for module assets.
  • Chores

    • Updated example CSS/markup and switched emit API usage to constructor-style calls.

✏️ Tip: You can customize this high-level summary in your review settings.

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Nov 27, 2025

🦋 Changeset detected

Latest commit: 13ac8f0

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

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

💥 An error occurred when fetching the changed packages and changesets in this PR
Some errors occurred when validating the changesets config:
The package or glob expression "farm-docs" is specified in the `ignore` option but it is not found in the project. You may have misspelled the package name or provided an invalid glob expression. Note that glob expressions must be defined according to https://www.npmjs.com/package/micromatch.
The package or glob expression "bench" is specified in the `ignore` option but it is not found in the project. You may have misspelled the package name or provided an invalid glob expression. Note that glob expressions must be defined according to https://www.npmjs.com/package/micromatch.

@Nirvana-Jie Nirvana-Jie requested review from Copilot and wre232114 and removed request for Copilot November 27, 2025 01:39
@vercel
Copy link
Copy Markdown

vercel bot commented Nov 27, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
farm-docs Ready Ready Preview, Comment Dec 26, 2025 1:07am

@Nirvana-Jie Nirvana-Jie requested a review from Copilot November 27, 2025 01:39
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Nov 27, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

📝 Walkthrough

Walkthrough

Centralized CSS URL resolution by adding a computed normalized_public_path and a private resolve_url in SourceReplacer; resource selection prefers a primary marker. Core adds RESOURCE_META_PRIMARY_KEY, EmitFileParams.is_primary, and a new constructor; call sites and examples updated accordingly.

Changes

Cohort / File(s) Summary
SourceReplacer changes
crates/plugin_css/src/source_replacer.rs
Reworked SourceReplacer: removed public public_path, added private normalized_public_path, reordered fields, added fn resolve_url(&self, source: &str) -> Option<String> that resolves CSS url() via ModuleGraph, filters resources by origin, prefers resource with RESOURCE_META_PRIMARY_KEY == "true", and returns normalized_public_path + resource.name. VisitMut now delegates URL resolution to resolve_url.
Dep analyzer: unify extraction & skip empty
crates/plugin_css/src/dep_analyzer.rs
Unified extraction of source from UrlValue (Str/Raw), added guard to skip empty/whitespace-only sources, and consolidated insert_dep to a single insertion path.
Core: primary resource support & EmitFileParams API
crates/core/src/context/mod.rs, crates/core/src/resource/mod.rs
Added RESOURCE_META_PRIMARY_KEY constant. EmitFileParams gains pub is_primary: Option<bool> (serde default Some(true)), a new(...) constructor, with_primary(...) builder, and default_is_primary() helper. emit_file logic updated to mark/unmark primary resource metadata when is_primary is true.
EmitFileParams call-site updates
crates/plugin_static_assets/src/lib.rs, rust-plugins/dts/src/lib.rs
Replaced struct-literal EmitFileParams { ... } with EmitFileParams::new(resolved_path, name, content, resource_type) at call sites.
Examples: CSS & JSX additions
examples/css-url/src/main.css, examples/css-url/src/main.tsx, examples/css-url/src/test.css
Added several @import variants and new classes .main-4.main-7 (including empty/whitespace url() cases), corresponding JSX elements in main.tsx; added test.css; removed an earlier .main-3 block.
Changeset
.changeset/mighty-pears-carry.md
New changeset documenting support for empty CSS url() values and primary resource handling via is_primary in EmitFileParams.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant CSS_AST as CSS AST (visitor)
  participant SourceReplacer as SourceReplacer
  participant ModuleGraph as ModuleGraph
  participant Resources as ResourcesMap
  participant Public as NormalizedPublicPath

  CSS_AST->>SourceReplacer: visit url(...) (Str/Raw)
  SourceReplacer->>SourceReplacer: normalize source & early-ignore checks
  SourceReplacer->>ModuleGraph: resolve module (ResolveKind::CssUrl)
  ModuleGraph-->>SourceReplacer: dependency module id
  SourceReplacer->>Resources: filter resources by origin == module id
  alt multiple resources
    Resources-->>SourceReplacer: select primary if RESOURCE_META_PRIMARY_KEY=="true" else first
  else single resource
    Resources-->>SourceReplacer: resource
  end
  SourceReplacer->>Public: prepend normalized_public_path
  SourceReplacer-->>CSS_AST: replace url(...) with computed public path
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • wre232114
  • ErKeLost
  • fu050409

Poem

🐰 I hopped through paths both trimmed and wide,

I found the primary where stray urls hide,
I nudged the paths to end with just one slash,
Now resources line up tidy in a stash,
🥕✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main changes: fixing empty URL handling in CSS and performance optimization through refactored URL resolution.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/css-url-resolver-improvements

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
examples/css-url/src/main.css (1)

3-3: Consider adding a comment explaining this test case.

An empty url() in an example file might confuse developers browsing examples. A brief comment indicating this tests empty URL handling would improve clarity.

 .main {
   color: red;
+  /* Test case: empty url() should be handled gracefully without panics */
   background: url();
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8d8f0ba and ea98a58.

📒 Files selected for processing (2)
  • crates/plugin_css/src/source_replacer.rs (3 hunks)
  • examples/css-url/src/main.css (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
crates/plugin_css/src/source_replacer.rs (2)
crates/plugin_css/src/dep_analyzer.rs (2)
  • is_source_ignored (75-81)
  • new (14-19)
crates/toolkit/src/resolve/path_start_with_alias.rs (1)
  • is_start_with_alias (9-31)
🔇 Additional comments (4)
crates/plugin_css/src/source_replacer.rs (4)

17-23: LGTM!

The struct refactoring with normalized_public_path and reverse_resource_map cleanly supports the performance optimization (O(1) lookups) and precomputation goals.


33-53: LGTM!

The path normalization correctly handles edge cases (empty string, multiple slashes) and ensures a consistent trailing slash for URL construction. The reverse_resource_map construction efficiently filters to module-origin resources.


64-72: Good fix for empty URL handling.

The early return for empty strings on lines 66-68 correctly addresses the empty url() issue described in the PR. This prevents invalid URLs from being processed further.


88-109: LGTM!

The refactored visit_mut_url cleanly delegates to resolve_url and only updates the URL value when a valid resource is found (lines 99-105). This prevents the previous panic behavior and leaves unresolved URLs unchanged.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR refactors the CSS URL resolver in plugin_css to improve performance and handle edge cases, specifically empty url() declarations. The changes replace O(n) linear searches with O(1) HashMap lookups and pre-compute the normalized public path.

Key Changes:

  • Introduced early return for empty URL strings to prevent processing errors
  • Pre-built a reverse resource map (ModuleIdresource_name) for O(1) lookups instead of iterating through all resources
  • Pre-computed normalized_public_path in the constructor to avoid repeated calculations

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
examples/css-url/src/main.css Changed to test empty url() handling by replacing valid URL with url()
crates/plugin_css/src/source_replacer.rs Refactored URL resolution with performance optimizations and empty URL handling

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

if is_source_ignored(source) && !is_start_with_alias(&self.alias, source) {
return Some(source.to_string());
}

Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

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

Trailing whitespace on line 73 after the closing brace. This should be removed to maintain consistent formatting.

Suggested change

Copilot uses AI. Check for mistakes.
Comment on lines +64 to +68
fn resolve_url(&self, source: &str) -> Option<String> {
// Ignore empty strings
if source.is_empty() {
return None;
}
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

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

The new empty URL handling logic (returning None when source.is_empty()) lacks test coverage. While there are existing tests in crates/plugin_css/tests/analyze_deps.rs for various URL scenarios, there's no test case for empty url() declarations.

Consider adding a test case that verifies:

  1. Empty url() is handled gracefully without panicking
  2. The CSS output remains unchanged or handles the empty URL appropriately
  3. No dependencies are added for empty URLs

Copilot uses AI. Check for mistakes.
Comment on lines +74 to +81
let dep_module = self.module_graph.get_dep_by_source(
&self.module_id,
source,
Some(ResolveKind::CssUrl),
);

if let Some(resource_name) = self.reverse_resource_map.get(&dep_module) {
return Some(format!("{}{}", self.normalized_public_path, resource_name));
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

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

The call to get_dep_by_source is missing error handling. This method panics when the dependency is not found (as seen in its implementation). The old code wrapped this in a loop that would panic with a more descriptive message if no resource was found, but the new code silently returns None, which could mask legitimate errors.

For URLs that should resolve but are missing from the module graph, the old code would panic with: "can not find resource: resolving {source} for {module_id}. dep: {dep_module}". The new code will silently return None, causing the URL to remain unchanged in the CSS output. This could lead to broken references that are difficult to debug.

Consider either:

  1. Adding a panic/error when reverse_resource_map.get(&dep_module) returns None for non-ignored sources, or
  2. Using get_dep_by_source_optional instead and handling the None case appropriately.
Suggested change
let dep_module = self.module_graph.get_dep_by_source(
&self.module_id,
source,
Some(ResolveKind::CssUrl),
);
if let Some(resource_name) = self.reverse_resource_map.get(&dep_module) {
return Some(format!("{}{}", self.normalized_public_path, resource_name));
let dep_module = match self.module_graph.get_dep_by_source(
&self.module_id,
source,
Some(ResolveKind::CssUrl),
) {
dep_module => dep_module,
};
if let Some(resource_name) = self.reverse_resource_map.get(&dep_module) {
return Some(format!("{}{}", self.normalized_public_path, resource_name));
} else {
// Only panic for non-ignored sources
if !is_source_ignored(source) {
panic!(
"can not find resource: resolving {} for {}. dep: {}",
source, self.module_id, dep_module
);
}

Copilot uses AI. Check for mistakes.
.main {
color: red;
background: url(/@/logo.png);
background: url();
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

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

[nitpick] The example file change from url(/@/logo.png) to url() appears to be for testing the empty URL handling. However, this makes the example less useful as a demonstration. The empty url() will now be silently ignored rather than processed, which means this example no longer demonstrates actual URL resolution.

Consider:

  1. Keeping the original valid URL in the main example, or
  2. Adding a separate test file specifically for edge cases like empty URLs rather than changing the primary example.
Suggested change
background: url();
background: url(/@/logo.png);

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

@wre232114 wre232114 left a comment

Choose a reason for hiding this comment

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

Add a changeset

public_path: String,
alias: Vec<AliasItem>,
normalized_public_path: String,
reverse_resource_map: HashMap<ModuleId, String>,
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.

A Module id may be mapped to multiple resources, we should use HashMap<ModuleId, Vec> here. BTW, we should only map css related module resource map here.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (1)
crates/plugin_css/src/source_replacer.rs (1)

53-57: Add an explicit test for empty url() handling

The new early return for source.is_empty() fixes the panic for empty url(), but this path doesn’t appear to be covered by tests yet. Adding a small fixture that exercises url("") / url('') / url() (whatever SWC parses as empty) would help prevent regressions.

🧹 Nitpick comments (3)
examples/css-url/src/main.tsx (1)

19-25: Minor indentation inconsistency.

Lines 20 and 23 have an extra leading space compared to the existing <div> blocks above (e.g., line 10, 14, 17). This is purely cosmetic but worth aligning for consistency.

-     <div className='main-4'>
+    <div className='main-4'>
       main-4
     </div>
-     <div className='main-5'>
+    <div className='main-5'>
       main-5
     </div>
crates/plugin_css/src/source_replacer.rs (2)

53-95: Good defensive URL resolution; consider tightening behavior for ignored sources and optimizing resource lookup

The new resolve_url fixes the empty url() panic and replaces get_dep_by_source with the optional variant, which is a solid safety improvement. Two follow‑ups to consider:

  1. Ignored sources rewriting AST
    For is_source_ignored(source) && !is_start_with_alias(...) you return Some(source.to_string()), which causes visit_mut_url to rewrite the node to UrlValue::Str with raw: None. That slightly changes how ignored URLs (e.g. data: or external URLs) are serialized because the original raw lexeme is lost. If preserving exact output for ignored URLs matters, you could instead return None here and let the caller leave the AST untouched.

  2. Performance of matching_resources lookup
    matching_resources is built by scanning self.resources_map.values() on every resolve_url call, which is O(N_resources) per URL. For stylesheets with many URLs and a large resources_map, this can dominate runtime. Since the access pattern is ModuleId -> [Resource], consider precomputing a reverse map once in new (e.g. HashMap<ModuleId, Vec<&Resource>> or similar, possibly CSS‑only) so resolve_url does a cheap hash lookup plus a small linear scan over that vector.

Both items are non‑blocking but would align the implementation more closely with the stated perf and behavior goals.


98-115: URL visitor correctly delegates to resolve_url, with a minor behavioral change for ignored URLs

The visitor now:

  • Delegates resolution for both UrlValue::Str and UrlValue::Raw to resolve_url.
  • Only rewrites the node when a concrete string is returned, avoiding accidental panics or overwriting on failures.

One side effect (due to the resolve_url branch for ignored sources) is that ignored URLs will always be rewritten to UrlValue::Str with raw: None, even if they started as Raw. If you adopt the suggestion to return None for ignored sources, this visitor will automatically preserve the original AST shape for those cases.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ea98a58 and 8224f66.

📒 Files selected for processing (8)
  • .changeset/mighty-pears-carry.md (1 hunks)
  • crates/core/src/context/mod.rs (4 hunks)
  • crates/core/src/resource/mod.rs (1 hunks)
  • crates/plugin_css/src/source_replacer.rs (4 hunks)
  • crates/plugin_static_assets/src/lib.rs (1 hunks)
  • examples/css-url/src/main.css (1 hunks)
  • examples/css-url/src/main.tsx (1 hunks)
  • rust-plugins/dts/src/lib.rs (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
rust-plugins/dts/src/lib.rs (1)
crates/core/src/context/mod.rs (6)
  • new (72-101)
  • new (301-313)
  • new (433-435)
  • new (447-449)
  • new (461-463)
  • new (491-504)
crates/core/src/context/mod.rs (1)
crates/core/src/resource/mod.rs (1)
  • default (147-159)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (19)
  • GitHub Check: Spell
  • GitHub Check: call-rust-build / Build and Upload Artifacts - android-arm64
  • GitHub Check: call-rust-build / Build and Upload Artifacts - linux-arm64-gnu
  • GitHub Check: call-rust-build / Build and Upload Artifacts - linux-x64-gnu
  • GitHub Check: call-rust-build / Build and Upload Artifacts - android-arm-eabi
  • GitHub Check: call-rust-build / Build and Upload Artifacts - win32-x64-msvc
  • GitHub Check: call-rust-build / Build and Upload Artifacts - linux-arm-gnueabihf
  • GitHub Check: call-rust-build / Build and Upload Artifacts - linux-arm64-musl
  • GitHub Check: call-rust-build / Build and Upload Artifacts - darwin-x64
  • GitHub Check: call-rust-build / Build and Upload Artifacts - linux-x64-musl
  • GitHub Check: call-rust-build / Build and Upload Artifacts - win32-arm64-msvc
  • GitHub Check: call-rust-build / Build and Upload Artifacts - win32-ia32-msvc
  • GitHub Check: call-rust-build / Build and Upload Artifacts - x86_64-unknown-freebsd
  • GitHub Check: Type Check
  • GitHub Check: Test - windows-latest
  • GitHub Check: Test - macos-latest
  • GitHub Check: Test - ubuntu-latest
  • GitHub Check: Check
  • GitHub Check: Rust Code Lint
🔇 Additional comments (10)
.changeset/mighty-pears-carry.md (1)

1-6: LGTM!

The changeset correctly documents the patches with an accurate description of the fixes: empty URL handling and multi-resource primary field support.

crates/core/src/resource/mod.rs (1)

14-16: LGTM!

The constant is well-named, appropriately documented, and follows the existing naming conventions. The farm_ prefix helps prevent collisions with user-defined metadata keys.

examples/css-url/src/main.css (1)

28-46: LGTM!

Good addition of test cases for both empty URL variants (url() and url('')). This properly addresses the prior review feedback by adding new test cases instead of modifying existing ones, ensuring the alias URL test remains intact.

rust-plugins/dts/src/lib.rs (1)

113-118: LGTM!

Clean migration to the new EmitFileParams::new() constructor. The default is_primary: Some(true) is appropriate for DTS files since they represent the primary output for their source module.

crates/plugin_static_assets/src/lib.rs (1)

271-276: LGTM!

Consistent migration to EmitFileParams::new(). Static assets are correctly marked as primary by default, which aligns with expected URL resolution behavior.

crates/core/src/context/mod.rs (3)

165-181: Verify lock scope and ordering.

The current implementation acquires the lock to clear existing primary markers (lines 170-176), then releases it before acquiring it again to insert the new resource (lines 203-206). This is correct for avoiding deadlocks, but worth noting:

  1. If another thread emits a resource for the same module between the two lock acquisitions, both could end up marked as primary until the second thread's clearing pass runs.
  2. This is likely acceptable given typical single-threaded emit patterns during transform.

If strict atomicity is required, consider holding a single lock scope for both operations. Otherwise, the current approach is fine for the expected use case.


472-474: LGTM!

The default_is_primary helper function correctly provides the serde default value.


476-510: Well-designed API additions.

The EmitFileParams enhancements are clean:

  • is_primary field with appropriate serde handling (defaulting to true, skipping serialization when None)
  • new() constructor that sets sensible defaults
  • with_primary() builder for explicit control when needed

This provides a good balance between convenience (most callers use new() with default primary=true) and flexibility (explicit override via builder).

crates/plugin_css/src/source_replacer.rs (2)

26-52: Normalized public path construction looks correct and robust

The normalized_public_path logic correctly handles "", "/" and multiple trailing slashes, and centralizing this in new avoids repeated work in hot paths. No correctness issues spotted here.


121-145: Using get_dep_by_source_optional in stylesheet import handling removes panic risk

Switching visit_mut_stylesheet to get_dep_by_source_optional(...CssAtImport) and gating rule removal on .is_some() ensures that missing @import dependencies no longer panic the build; they simply leave the rule in place. This is the right trade‑off for robustness, and the existing !is_source_ignored(&source) guard keeps behavior consistent with URL resolution.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.

Comments suppressed due to low confidence (1)

crates/core/src/context/mod.rs:206

  • The resources_map lock is acquired twice in this function: once at line 170 to clear primary markers, and again at line 204 to insert the new resource. This creates a window where other threads could modify the resources_map between releases. Consider holding the lock for the entire operation or restructuring to acquire it once:
let mut resources = self.resources_map.lock();
// Clear previous primary markers
if should_mark_primary {
  for resource in resources.values_mut() {
    if matches!(&resource.origin, ResourceOrigin::Module(m_id) if m_id == &module_id) {
      resource.meta.remove(RESOURCE_META_PRIMARY_KEY);
    }
  }
}
// Insert new resource
resources.insert(params.name.clone(), resource);
    if should_mark_primary {
      let mut resources = self.resources_map.lock();
      for resource in resources.values_mut() {
        if matches!(&resource.origin, ResourceOrigin::Module(m_id) if m_id == &module_id) {
          resource.meta.remove(RESOURCE_META_PRIMARY_KEY);
        }
      }
    }

    let mut meta = HashMap::default();
    if should_mark_primary {
      meta.insert(RESOURCE_META_PRIMARY_KEY.to_string(), "true".to_string());
    }

    let resource = Resource {
      name: params.name.clone(),
      name_hash: "".to_string(),
      bytes: params.content,
      emitted: false,
      should_transform_output_filename: true,
      resource_type: params.resource_type,
      origin: ResourceOrigin::Module(module_id.clone()),
      meta,
      special_placeholders: Default::default(),
    };

    if self.cache_manager.enable() {
      self.write_metadata(
        "builtin:emit_file",
        resource.clone(),
        Some(MetadataOption::default().refer(vec![module_id])),
      );
    }

    self
      .resources_map
      .lock()
      .insert(params.name.clone(), resource);

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +483 to +487
/// Mark this resource as the primary one when multiple resources exist for the same module.
/// Defaults to true if not specified.
/// When a module generates multiple resources, the primary one is used for URL resolution.
#[serde(default = "default_is_primary", skip_serializing_if = "Option::is_none")]
pub is_primary: Option<bool>,
Copy link

Copilot AI Nov 29, 2025

Choose a reason for hiding this comment

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

The documentation states "Mark this resource as the primary one when multiple resources exist for the same module" and "Defaults to true if not specified." However, when is_primary is None or not provided, the behavior defaults to true (line 166 with unwrap_or(true)), which means every resource emitted will be marked as primary and will clear previous primary markers. This could be confusing.

Consider either:

  1. Making the default false so resources aren't automatically marked as primary unless explicitly requested, or
  2. Clarifying in the documentation that the first resource emitted for a module will be primary by default, and subsequent resources need to explicitly set is_primary: true to change which one is primary.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (1)
examples/css-url/src/main.tsx (1)

20-25: New main-4/main-5 blocks correctly extend the example; consider a brief intent comment

The additional divs are structurally consistent with the rest of the component and give you concrete elements to exercise the url() and url('') cases the plugin now handles.

Optionally, add a short comment here or in main.css noting that main-4 and main-5 are intentionally using empty url() values, so future readers understand these are fixtures for the CSS URL resolver behavior rather than accidental broken styles.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8224f66 and eba938d.

📒 Files selected for processing (1)
  • examples/css-url/src/main.tsx (1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (17)
  • GitHub Check: call-rust-build / Build and Upload Artifacts - linux-x64-musl
  • GitHub Check: call-rust-build / Build and Upload Artifacts - linux-arm-gnueabihf
  • GitHub Check: call-rust-build / Build and Upload Artifacts - android-arm-eabi
  • GitHub Check: call-rust-build / Build and Upload Artifacts - android-arm64
  • GitHub Check: call-rust-build / Build and Upload Artifacts - linux-arm64-musl
  • GitHub Check: call-rust-build / Build and Upload Artifacts - win32-arm64-msvc
  • GitHub Check: call-rust-build / Build and Upload Artifacts - linux-arm64-gnu
  • GitHub Check: call-rust-build / Build and Upload Artifacts - win32-ia32-msvc
  • GitHub Check: call-rust-build / Build and Upload Artifacts - linux-x64-gnu
  • GitHub Check: call-rust-build / Build and Upload Artifacts - win32-x64-msvc
  • GitHub Check: call-rust-build / Build and Upload Artifacts - darwin-arm64
  • GitHub Check: call-rust-build / Build and Upload Artifacts - darwin-x64
  • GitHub Check: call-rust-build / Build and Upload Artifacts - x86_64-unknown-freebsd
  • GitHub Check: Type Check
  • GitHub Check: Test - ubuntu-latest
  • GitHub Check: Test - windows-latest
  • GitHub Check: Test - macos-latest

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

Caution

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

⚠️ Outside diff range comments (1)
crates/core/src/context/mod.rs (1)

162-207: Primary resource marking is not atomic across concurrent emit_file calls

The clearing of previous primary markers (lines 169‑175) and insertion of the new Resource (lines 203‑206) use two separate resources_map locks. Two threads calling emit_file concurrently for the same module_id with is_primary = true can each clear existing markers and then insert their own resource, leaving multiple resources marked primary for that module.

To keep the “single primary per module” invariant that SourceReplacer relies on, consider making the clear‑and‑insert sequence atomic under one lock, e.g.:

   let module_id = self.str_to_module_id(&params.resolved_path);

-  // Determine if this resource should be marked as primary (defaults to true)
-  let should_mark_primary = params.is_primary.unwrap_or(true);
-
-  // If user marks this resource as primary, clear previous primary markers for this module
-  if should_mark_primary {
-    let mut resources = self.resources_map.lock();
-    for resource in resources.values_mut() {
-      if matches!(&resource.origin, ResourceOrigin::Module(m_id) if m_id == &module_id) {
-        resource.meta.remove(RESOURCE_META_PRIMARY_KEY);
-      }
-    }
-  }
-
-  let mut meta = HashMap::default();
-  if should_mark_primary {
-    meta.insert(RESOURCE_META_PRIMARY_KEY.to_string(), "true".to_string());
-  }
-
-  let resource = Resource {
+  let should_mark_primary = params.is_primary.unwrap_or(true);
+
+  let mut meta = HashMap::default();
+  if should_mark_primary {
+    meta.insert(RESOURCE_META_PRIMARY_KEY.to_string(), "true".to_string());
+  }
+
+  let resource = Resource {
     name: params.name.clone(),
@@
-    origin: ResourceOrigin::Module(module_id.clone()),
-    meta,
+    origin: ResourceOrigin::Module(module_id.clone()),
+    meta,
     special_placeholders: Default::default(),
   };
@@
-  self
-    .resources_map
-    .lock()
-    .insert(params.name.clone(), resource);
+  {
+    let mut resources = self.resources_map.lock();
+    if should_mark_primary {
+      for existing in resources.values_mut() {
+        if matches!(&existing.origin, ResourceOrigin::Module(m_id) if m_id == &module_id) {
+          existing.meta.remove(RESOURCE_META_PRIMARY_KEY);
+        }
+      }
+    }
+    resources.insert(params.name.clone(), resource);
+  }

This way, at most one resource per module can end up marked primary, even under concurrent emission.

♻️ Duplicate comments (1)
crates/plugin_css/src/source_replacer.rs (1)

53-94: URL resolution behavior is sound, but per‑URL scan over all resources is still O(n)

The new resolve_url nicely:

  • skips empty/whitespace url() values,
  • leaves ignored sources untouched (unless matched by an alias),
  • avoids panics by using get_dep_by_source_optional, and
  • prefers resources marked with RESOURCE_META_PRIMARY_KEY when multiple resources share the same origin.

However, for every CSS url(...) you still do:

self
  .resources_map
  .values()
  .filter(|r| matches!(&r.origin, ResourceOrigin::Module(m_id) if m_id == &dep_module))
  .collect();

which is O(total_resources) per URL. If there are many resources and many CSS URLs, this remains a potential hot path.

Consider pre‑indexing once into a reverse map, e.g. HashMap<ModuleId, Vec<&Resource>> (or resource names), possibly filtered to the CSS‑relevant resources, and then doing O(1) lookups in resolve_url. That would align the implementation with the stated performance goals.

🧹 Nitpick comments (1)
crates/core/src/context/mod.rs (1)

472-509: EmitFileParams::is_primary API semantics may be more complex than needed

The combination of:

  • fn default_is_primary() -> Option<bool> { Some(true) } (lines 472‑474),
  • EmitFileParams::new setting is_primary: Some(true) (lines 497‑503), and
  • params.is_primary.unwrap_or(true) in emit_file

means resources are primary by default everywhere, and None is effectively treated the same as Some(true). In practice, only Some(false) ever yields a non‑primary resource.

Given that behavior, you might simplify the API to a plain bool:

#[serde(default)]
pub is_primary: bool;

with EmitFileParams::new using is_primary: true and with_primary toggling it, and adjust emit_file to read the bool directly. This removes an unnecessary Option layer and makes the default behavior clearer to callers.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 64706a2 and eb75931.

📒 Files selected for processing (10)
  • .changeset/mighty-pears-carry.md (1 hunks)
  • crates/core/src/context/mod.rs (4 hunks)
  • crates/core/src/resource/mod.rs (1 hunks)
  • crates/plugin_css/src/dep_analyzer.rs (1 hunks)
  • crates/plugin_css/src/source_replacer.rs (4 hunks)
  • crates/plugin_static_assets/src/lib.rs (1 hunks)
  • examples/css-url/src/main.css (2 hunks)
  • examples/css-url/src/main.tsx (1 hunks)
  • examples/css-url/src/test.css (1 hunks)
  • rust-plugins/dts/src/lib.rs (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (8)
  • crates/plugin_static_assets/src/lib.rs
  • examples/css-url/src/main.tsx
  • examples/css-url/src/main.css
  • crates/plugin_css/src/dep_analyzer.rs
  • .changeset/mighty-pears-carry.md
  • rust-plugins/dts/src/lib.rs
  • crates/core/src/resource/mod.rs
  • examples/css-url/src/test.css
🧰 Additional context used
🧬 Code graph analysis (1)
crates/plugin_css/src/source_replacer.rs (3)
crates/plugin_css/src/dep_analyzer.rs (2)
  • is_source_ignored (75-81)
  • new (14-19)
crates/toolkit/src/resolve/path_start_with_alias.rs (1)
  • is_start_with_alias (9-31)
packages/core/src/types/binding.ts (1)
  • ResolveKind (15-26)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (19)
  • GitHub Check: call-rust-build / Build and Upload Artifacts - linux-arm64-gnu
  • GitHub Check: call-rust-build / Build and Upload Artifacts - android-arm64
  • GitHub Check: call-rust-build / Build and Upload Artifacts - win32-arm64-msvc
  • GitHub Check: call-rust-build / Build and Upload Artifacts - linux-arm-gnueabihf
  • GitHub Check: call-rust-build / Build and Upload Artifacts - android-arm-eabi
  • GitHub Check: call-rust-build / Build and Upload Artifacts - linux-arm64-musl
  • GitHub Check: call-rust-build / Build and Upload Artifacts - darwin-x64
  • GitHub Check: call-rust-build / Build and Upload Artifacts - win32-ia32-msvc
  • GitHub Check: call-rust-build / Build and Upload Artifacts - win32-x64-msvc
  • GitHub Check: call-rust-build / Build and Upload Artifacts - linux-x64-musl
  • GitHub Check: call-rust-build / Build and Upload Artifacts - linux-x64-gnu
  • GitHub Check: call-rust-build / Build and Upload Artifacts - x86_64-unknown-freebsd
  • GitHub Check: Type Check
  • GitHub Check: Check
  • GitHub Check: Test - macos-latest
  • GitHub Check: Test - ubuntu-latest
  • GitHub Check: Test - windows-latest
  • GitHub Check: Spell
  • GitHub Check: Rust Code Lint
🔇 Additional comments (3)
crates/plugin_css/src/source_replacer.rs (3)

26-51: normalized_public_path construction looks robust

The normalization in SourceReplacer::new correctly handles:

  • empty public_path and slash-only values by normalizing to "/", and
  • non-empty values by trimming trailing slashes and appending a single /.

This should avoid subtle double‑slash or empty‑path issues in URL generation while keeping behavior predictable.


98-115: visit_mut_url refactor correctly centralizes URL handling and skips invalid URLs

Delegating both Str and Raw cases to resolve_url and only rewriting when it returns Some(...) improves clarity and ensures:

  • empty url()s and unresolved URLs leave the AST untouched, and
  • all replacement logic (including ignore/alias rules and primary selection) lives in a single place.

This looks consistent with the intended behavior change to avoid panics while preserving valid URLs.


134-143: Using get_dep_by_source_optional for @import avoids hard panics

Switching visit_mut_stylesheet to get_dep_by_source_optional(...).is_some() for CssAtImport resolution removes the previous panic risk when an @import cannot be resolved, while still removing rules only when there is an actual dependency edge.

This aligns with the more defensive handling you added for url() resolution and should make CSS processing more robust.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
crates/plugin_css/src/source_replacer.rs (1)

68-72: O(n) linear search contradicts PR's claimed O(1) HashMap lookup.

The PR description states: "Replaced O(n) linear search through all resources for each url() with O(1) HashMap lookups using a pre-built reverse resource map." However, this code still performs an O(n) .filter() iteration through all resources for each url() encountered in CSS files.

For projects with many resources and CSS files containing multiple url() references, this could significantly impact build performance.

🔎 Suggested fix: Pre-build a reverse map for O(1) lookups

Modify the SourceReplacer struct to include a reverse map:

 pub struct SourceReplacer<'a> {
   module_id: ModuleId,
   module_graph: &'a ModuleGraph,
   alias: Vec<AliasItem>,
   normalized_public_path: String,
-  resources_map: &'a HashMap<String, Resource>,
+  reverse_resource_map: HashMap<ModuleId, Vec<&'a Resource>>,
 }

In the constructor, build the reverse map once:

   ) -> Self {
     let normalized_public_path = if public_path.is_empty() {
       "/".to_string()
     } else {
       let trimmed = public_path.trim_end_matches('/');
       if trimmed.is_empty() {
         "/".to_string()
       } else {
         format!("{}/", trimmed)
       }
     };
+
+    // Build reverse map: ModuleId -> Vec<Resource>
+    let mut reverse_resource_map: HashMap<ModuleId, Vec<&Resource>> = HashMap::default();
+    for resource in resources_map.values() {
+      if let ResourceOrigin::Module(m_id) = &resource.origin {
+        reverse_resource_map.entry(m_id.clone()).or_default().push(resource);
+      }
+    }

     Self {
       module_id,
       module_graph,
       alias,
       normalized_public_path,
-      resources_map,
+      reverse_resource_map,
     }
   }

Then in resolve_url, use O(1) lookup:

-    let matching_resources: Vec<&Resource> = self
-      .resources_map
-      .values()
-      .filter(|r| matches!(&r.origin, ResourceOrigin::Module(m_id) if m_id == &dep_module))
-      .collect();
+    let matching_resources = self
+      .reverse_resource_map
+      .get(&dep_module)?;

-    if matching_resources.is_empty() {
-      return None;
-    }
-
-    let resource = if matching_resources.len() > 1 {
+    let resource = if matching_resources.len() > 1 {
       matching_resources
         .iter()
-        .find(|res| {
+        .find(|&res| {
           res
             .meta
             .get(RESOURCE_META_PRIMARY_KEY)
             .map(|v| v == "true")
             .unwrap_or(false)
         })
-        .unwrap_or(&matching_resources[0])
+        .copied()
+        .unwrap_or(matching_resources[0])
     } else {
-      &matching_resources[0]
+      matching_resources[0]
     };
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between eb75931 and 13ac8f0.

📒 Files selected for processing (10)
  • .changeset/mighty-pears-carry.md
  • crates/core/src/context/mod.rs
  • crates/core/src/resource/mod.rs
  • crates/plugin_css/src/dep_analyzer.rs
  • crates/plugin_css/src/source_replacer.rs
  • crates/plugin_static_assets/src/lib.rs
  • examples/css-url/src/main.css
  • examples/css-url/src/main.tsx
  • examples/css-url/src/test.css
  • rust-plugins/dts/src/lib.rs
🚧 Files skipped from review as they are similar to previous changes (8)
  • crates/core/src/resource/mod.rs
  • examples/css-url/src/test.css
  • crates/plugin_static_assets/src/lib.rs
  • rust-plugins/dts/src/lib.rs
  • .changeset/mighty-pears-carry.md
  • examples/css-url/src/main.tsx
  • crates/plugin_css/src/dep_analyzer.rs
  • examples/css-url/src/main.css
🧰 Additional context used
🧬 Code graph analysis (2)
crates/core/src/context/mod.rs (1)
crates/core/src/resource/mod.rs (1)
  • default (147-159)
crates/plugin_css/src/source_replacer.rs (3)
crates/plugin_css/src/dep_analyzer.rs (2)
  • is_source_ignored (75-81)
  • new (14-19)
crates/toolkit/src/resolve/path_start_with_alias.rs (1)
  • is_start_with_alias (9-31)
packages/core/src/types/binding.ts (1)
  • ResolveKind (15-26)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (19)
  • GitHub Check: call-rust-build / Build and Upload Artifacts - linux-arm64-gnu
  • GitHub Check: call-rust-build / Build and Upload Artifacts - linux-arm-gnueabihf
  • GitHub Check: call-rust-build / Build and Upload Artifacts - win32-arm64-msvc
  • GitHub Check: call-rust-build / Build and Upload Artifacts - win32-ia32-msvc
  • GitHub Check: call-rust-build / Build and Upload Artifacts - android-arm64
  • GitHub Check: call-rust-build / Build and Upload Artifacts - android-arm-eabi
  • GitHub Check: call-rust-build / Build and Upload Artifacts - linux-x64-gnu
  • GitHub Check: call-rust-build / Build and Upload Artifacts - linux-arm64-musl
  • GitHub Check: call-rust-build / Build and Upload Artifacts - win32-x64-msvc
  • GitHub Check: call-rust-build / Build and Upload Artifacts - linux-x64-musl
  • GitHub Check: call-rust-build / Build and Upload Artifacts - darwin-x64
  • GitHub Check: call-rust-build / Build and Upload Artifacts - darwin-arm64
  • GitHub Check: call-rust-build / Build and Upload Artifacts - x86_64-unknown-freebsd
  • GitHub Check: Type Check
  • GitHub Check: Check
  • GitHub Check: Test - ubuntu-latest
  • GitHub Check: Test - macos-latest
  • GitHub Check: Test - windows-latest
  • GitHub Check: Rust Code Lint
🔇 Additional comments (3)
crates/core/src/context/mod.rs (1)

490-509: LGTM! Well-structured constructor and builder pattern.

The new() constructor correctly initializes is_primary to Some(true), aligning with the default behavior. The with_primary() builder method provides a clean API for callers who need to override the default.

crates/plugin_css/src/source_replacer.rs (2)

33-42: LGTM! Robust public path normalization.

The logic correctly handles empty paths, ensures a trailing slash, and covers the edge case where the path contains only slashes. Pre-computing this value in the constructor avoids repeated normalization on every URL resolution.


103-114: LGTM! Clean refactoring that eliminates code duplication.

The centralized resolve_url method handles both Str and Raw URL variants consistently, and the conditional update ensures URLs that can't be resolved remain unchanged. This improves maintainability without altering behavior.

Comment on lines +169 to +176
if should_mark_primary {
let mut resources = self.resources_map.lock();
for resource in resources.values_mut() {
if matches!(&resource.origin, ResourceOrigin::Module(m_id) if m_id == &module_id) {
resource.meta.remove(RESOURCE_META_PRIMARY_KEY);
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

O(n) iteration through all resources to clear primary markers.

On each emit_file call with is_primary=true (the default), this code locks the resources map and iterates through all resources to find those originating from the same module. For large projects with thousands of resources, this O(n) scan on every emission could become a performance bottleneck.

Consider maintaining a reverse index (HashMap<ModuleId, Vec<String>>) mapping module IDs to their resource names to enable O(1) lookups.

Would you like me to generate a refactored implementation using a reverse index for O(1) primary marker management?

Comment on lines +78 to +91
let resource = if matching_resources.len() > 1 {
matching_resources
.iter()
.find(|res| {
res
.meta
.get(RESOURCE_META_PRIMARY_KEY)
.map(|v| v == "true")
.unwrap_or(false)
})
.unwrap_or(&matching_resources[0])
} else {
&matching_resources[0]
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Non-deterministic fallback when no primary resource is marked.

When multiple resources match but none has RESOURCE_META_PRIMARY_KEY set, the code falls back to matching_resources[0]. Since HashMap iteration order is not guaranteed, this fallback could be non-deterministic across runs.

While the new primary-marking logic in emit_file should ensure at most one primary resource per module, edge cases (e.g., resources emitted before this PR, or external resources) might still trigger this path.

🔎 Suggested improvement for deterministic fallback

Consider sorting by resource name or timestamp before selecting the fallback:

     let resource = if matching_resources.len() > 1 {
-      matching_resources
+      let primary_resource = matching_resources
         .iter()
         .find(|res| {
           res
             .meta
             .get(RESOURCE_META_PRIMARY_KEY)
             .map(|v| v == "true")
             .unwrap_or(false)
-        })
-        .unwrap_or(&matching_resources[0])
+        });
+      
+      if let Some(res) = primary_resource {
+        res
+      } else {
+        // Fallback: use lexicographically first resource name for determinism
+        matching_resources.iter().min_by_key(|r| &r.name).unwrap()
+      }
     } else {
       &matching_resources[0]
     };
🤖 Prompt for AI Agents
In crates/plugin_css/src/source_replacer.rs around lines 78 to 91, the fallback
selection of a resource when multiple matches exist but none is marked as
primary is non-deterministic (uses matching_resources[0]); change this to a
deterministic choice by selecting the resource with a stable key (for example,
sort or use min_by on a stable field such as resource.name, resource.path, or a
timestamp) and use that as the fallback; ensure the comparison is
locale/OS-independent (e.g., use raw string ordering) and preserve the existing
primary-selection branch when a primary meta flag is present.

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.

3 participants