Repository: reflex-dev/reflex
Pull request #5121: Support for importing functions from tailwind plugins
Author: login: itsmeadarsh2008
Created: 2025-04-12T06:37:50Z | Merged: 2025-04-18T19:55:50Z

### All Submissions:

- [x] Have you followed the guidelines stated in [CONTRIBUTING.md](https://github.com/reflex-dev/reflex/blob/main/CONTRIBUTING.md) file?
- [x] Have you checked to ensure there aren't any other open [Pull Requests](https://github.com/reflex-dev/reflex/pulls) for the desired changed?

<!-- You can erase any parts of this template not applicable to your Pull Request. -->

### Type of change

Please delete options that are not relevant.

- [x] New feature (non-breaking change which adds functionality)
- [x] This change requires a documentation update

### New Feature Submission:

- [x] Does your submission pass the tests?
- [x] Have you linted your code locally prior to submission?

### Changes To Core Features:

- [x] Have you added an explanation of what your changes do and why you'd like us to include them?
- [x] Have you written new tests for your core changes, as applicable?
- [x] Have you successfully run tests with your changes locally?
---

### 🧩 Example Python Data Input

Here’s how you’d pass the data for plugins like `heroui()` and `themeVariants(...)`:

```python
plugins = [
    "@tailwindcss/forms",  # simple require
    {
        "name": "@heroui/theme",
        "import": {"name": "heroui", "from": "@heroui/theme"},
        "call": "heroui"
    },
    {
        "name": "tailwindcss-theme-variants",
        "import": {"name": "themeVariants", "from": "tailwindcss-theme-variants"},
        "call": "themeVariants",
        "args": {
            "themes": {
                "light": { "selector": ".light-theme" },
                "dark": { "selector": ".dark-theme" }
            }
        }
    }
]
```

---

### 🧠 Why This Is Better

- Ensures proper `require()` or `const { fn } = require('...')` imports.
- Maintains **clarity** and **control** over plugin execution.
- Compatible with plugins that expose multiple exports or need function calls.
- Generates **valid**, modern Tailwind config files.

---


Diff:
diff --git a/reflex/.templates/jinja/web/tailwind.config.js.jinja2 b/reflex/.templates/jinja/web/tailwind.config.js.jinja2
index 00ae98e0c6c..72993478bdb 100644
--- a/reflex/.templates/jinja/web/tailwind.config.js.jinja2
+++ b/reflex/.templates/jinja/web/tailwind.config.js.jinja2
@@ -1,32 +1,66 @@
-/** @type {import('tailwindcss').Config} */
-module.exports = {
-	content: {{content|json_dumps}},
-	theme: {{theme|json_dumps}},
-	plugins: [
-	{% for plugin in plugins %}
-		require({{plugin|json_dumps}}),
-	{% endfor %}
-	],
-	{% if presets is defined %}
-	presets: [
-	{% for preset in presets %}
-		require({{preset|json_dumps}})
-	{% endfor %}
-	],
-	{% endif %}
-	{% if darkMode is defined %}
-	darkMode: {{darkMode|json_dumps}},
-	{% endif %}
-	{% if corePlugins is defined %}
-	corePlugins: {{corePlugins|json_dumps}},
-	{% endif %}
-	{% if important is defined %}
-	important: {{important|json_dumps}},
-	{% endif %}
-	{% if prefix is defined %}
-	prefix: {{prefix|json_dumps}},
-	{% endif %}
-	{% if separator is defined %}
-	separator: {{separator|json_dumps}},
-	{% endif %}
+{# Helper macro to render JS objects and arrays #}
+{% macro render_js(val, indent=2, level=0) -%}
+{%- set space = ' ' * (indent * level) -%}
+{%- set next_space = ' ' * (indent * (level + 1)) -%}
+
+{%- if val is mapping -%}
+{
+{%- for k, v in val.items() %}
+{{ next_space }}{{ k if k is string and k.isidentifier() else k|tojson }}: {{ render_js(v, indent, level + 1) }}{{ "," if not loop.last }}
+{%- endfor %}
+{{ space }}}
+{%- elif val is iterable and val is not string -%}
+[
+{%- for item in val %}
+{{ next_space }}{{ render_js(item, indent, level + 1) }}{{ "," if not loop.last }}
+{%- endfor %}
+{{ space }}]
+{%- else -%}
+{{ val | tojson }}
+{%- endif -%}
+{%- endmacro %}
+
+{# Extract destructured imports from plugin dicts only #}
+{%- set imports = [] %}
+{%- for plugin in plugins if plugin is mapping and plugin.import is defined %}
+  {%- set _ = imports.append(plugin.import) %}
+{%- endfor %}
+
+/** @type {import('tailwindcss').Config} */
+{%- for imp in imports %}
+const { {{ imp.name }} } = require({{ imp.from | tojson }});  
+{%- endfor %}
+
+module.exports = {
+  content: {{ render_js(content) }},
+  theme: {{ render_js(theme) }},
+  {% if darkMode is defined %}darkMode: {{ darkMode | tojson }},{% endif %}
+  {% if corePlugins is defined %}corePlugins: {{ render_js(corePlugins) }},{% endif %}
+  {% if important is defined %}important: {{ important | tojson }},{% endif %}
+  {% if prefix is defined %}prefix: {{ prefix | tojson }},{% endif %}
+  {% if separator is defined %}separator: {{ separator | tojson }},{% endif %}
+  {% if presets is defined %}
+  presets: [
+    {% for preset in presets %}
+    require({{ preset | tojson }}){{ "," if not loop.last }}
+    {% endfor %}
+  ],
+  {% endif %}
+  plugins: [
+    {% for plugin in plugins %}
+      {% if plugin is mapping %}
+        {% if plugin.call is defined %}
+          {{ plugin.call }}(
+            {%- if plugin.args is defined -%}
+              {{ render_js(plugin.args) }}
+            {%- endif -%}
+          ){{ "," if not loop.last }}
+        {% else %}
+          require({{ plugin.name | tojson }}){{ "," if not loop.last }}
+        {% endif %}
+      {% else %}
+        require({{ plugin | tojson }}){{ "," if not loop.last }}
+      {% endif %}
+    {% endfor %}
+  ]
 };
diff --git a/reflex/utils/format.py b/reflex/utils/format.py
index 85b328a383d..cba91b4ddb9 100644
--- a/reflex/utils/format.py
+++ b/reflex/utils/format.py
@@ -643,17 +643,32 @@ def format_ref(ref: str) -> str:
     return f"ref_{clean_ref}"
 
 
-def format_library_name(library_fullname: str):
+def format_library_name(library_fullname: str | dict[str, Any]) -> str:
     """Format the name of a library.
 
     Args:
-        library_fullname: The fullname of the library.
+        library_fullname: The library reference, either as a string or a dictionary with a 'name' key.
 
     Returns:
         The name without the @version if it was part of the name
+
+    Raises:
+        KeyError: If library_fullname is a dictionary without a 'name' key.
+        TypeError: If library_fullname or its 'name' value is not a string.
     """
+    # If input is a dictionary, extract the 'name' key
+    if isinstance(library_fullname, dict):
+        if "name" not in library_fullname:
+            raise KeyError("Dictionary input must contain a 'name' key")
+        library_fullname = library_fullname["name"]
+
+    # Process the library name as a string
+    if not isinstance(library_fullname, str):
+        raise TypeError("Library name must be a string")
+
     if library_fullname.startswith("https://"):
         return library_fullname
+
     lib, at, version = library_fullname.rpartition("@")
     if not lib:
         lib = at + version
diff --git a/reflex/utils/prerequisites.py b/reflex/utils/prerequisites.py
index 2ec4a2ca560..a4612d5b5ce 100644
--- a/reflex/utils/prerequisites.py
+++ b/reflex/utils/prerequisites.py
@@ -1314,7 +1314,10 @@ def install_frontend_packages(packages: set[str], config: Config):
                 "--legacy-peer-deps",
                 "-d",
                 constants.Tailwind.VERSION,
-                *((config.tailwind or {}).get("plugins", [])),
+                *[
+                    plugin if isinstance(plugin, str) else plugin.get("name")
+                    for plugin in (config.tailwind or {}).get("plugins", [])
+                ],
             ],
             fallbacks=fallbacks,
             analytics_enabled=True,
diff --git a/tests/units/test_prerequisites.py b/tests/units/test_prerequisites.py
index 38513ce3250..be4399bd1b4 100644
--- a/tests/units/test_prerequisites.py
+++ b/tests/units/test_prerequisites.py
@@ -97,6 +97,8 @@ def test_update_next_config(config, export, expected_output):
             ["foo", "@bar/baz", "foo", "@bar/baz@3.2.1"],
             ["@bar/baz", "foo"],
         ),
+        (["@bar/baz", {"name": "foo"}], ["@bar/baz", "foo"]),
+        (["@bar/baz", {"name": "@foo/baz"}], ["@bar/baz", "@foo/baz"]),
     ),
 )
 def test_transpile_packages(transpile_packages, expected_transpile_packages):


Reviews:
user: login: adhami3310
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T19:23:25Z
user: login: itsmeadarsh2008
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T04:30:06Z
user: login: adhami3310
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T04:32:20Z
user: login: itsmeadarsh2008
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T04:36:39Z
user: login: adhami3310
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-18T19:55:45Z

Review comments:
diff_hunk: @@ -643,18 +643,69 @@ def format_ref(ref: str) -> str:
     return f"ref_{clean_ref}"
 
 
-def format_library_name(library_fullname: str):
-    """Format the name of a library.
+def format_library_name(
+    library_fullname: str | dict[str, Any],
+) -> str:
+    """Format the name of a library, removing version information.
+
+    Handles npm package formats including scoped packages (@org/package).
 
     Args:
-        library_fullname: The fullname of the library.
+        library_fullname: The library identifier, either as:
+            - string: package name with optional version (e.g., "foo", "@bar/baz", "foo@1.2.3")
+            - dict: with a "name" key (e.g., {"name": "@bar/baz"})
 
     Returns:
-        The name without the @version if it was part of the name
+        The normalized package name without version information
+
+    Raises:
+        ValueError: If the provided dictionary does not have a 'name' key.
     """
+    # Extract name from dictionary if needed
+    if isinstance(library_fullname, dict):
+        if "name" not in library_fullname:
+            raise ValueError("Dictionary input must have a 'name' key")
+        library_fullname = library_fullname["name"]
+
+    # Fast path for non-string inputs
+    if not isinstance(library_fullname, str):
+        return str(library_fullname)
+
+    # Fast path for URLs
     if library_fullname.startswith("https://"):
         return library_fullname
-    lib, at, version = library_fullname.rpartition("@")
+
+    # Fast path for packages without @ symbol
+    if "@" not in library_fullname:
+        return library_fullname
+
+    # Handle edge case: @library@version (no slash but starts with @)
+    if library_fullname.startswith("@") and "/" not in library_fullname:
+        # Check for a second @ symbol
+        second_at_index = library_fullname.find("@", 1)
+        if second_at_index != -1:
path: reflex/utils/format.py
user: login: adhami3310
body: it seems this code effectively does the same thing, why is it being changed?
created_at: 2025-04-17T19:23:25Z
author_association: MEMBER
diff_hunk: @@ -643,18 +643,69 @@ def format_ref(ref: str) -> str:
     return f"ref_{clean_ref}"
 
 
-def format_library_name(library_fullname: str):
-    """Format the name of a library.
+def format_library_name(
+    library_fullname: str | dict[str, Any],
+) -> str:
+    """Format the name of a library, removing version information.
+
+    Handles npm package formats including scoped packages (@org/package).
 
     Args:
-        library_fullname: The fullname of the library.
+        library_fullname: The library identifier, either as:
+            - string: package name with optional version (e.g., "foo", "@bar/baz", "foo@1.2.3")
+            - dict: with a "name" key (e.g., {"name": "@bar/baz"})
 
     Returns:
-        The name without the @version if it was part of the name
+        The normalized package name without version information
+
+    Raises:
+        ValueError: If the provided dictionary does not have a 'name' key.
     """
+    # Extract name from dictionary if needed
+    if isinstance(library_fullname, dict):
+        if "name" not in library_fullname:
+            raise ValueError("Dictionary input must have a 'name' key")
+        library_fullname = library_fullname["name"]
+
+    # Fast path for non-string inputs
+    if not isinstance(library_fullname, str):
+        return str(library_fullname)
+
+    # Fast path for URLs
     if library_fullname.startswith("https://"):
         return library_fullname
-    lib, at, version = library_fullname.rpartition("@")
+
+    # Fast path for packages without @ symbol
+    if "@" not in library_fullname:
+        return library_fullname
+
+    # Handle edge case: @library@version (no slash but starts with @)
+    if library_fullname.startswith("@") and "/" not in library_fullname:
+        # Check for a second @ symbol
+        second_at_index = library_fullname.find("@", 1)
+        if second_at_index != -1:
path: reflex/utils/format.py
user: login: itsmeadarsh2008
body: **TLDR:** The updated diff parses packages faster than previous one.

The updated implementation in the diff is faster and more robust because it adds various optimizations and fast paths to handle specific cases. Here's why:

1. **Fast Path for URLs**:
   - The line `if library_fullname.startswith("https://"): return library_fullname` is executed early if the input is a URL, avoiding unnecessary processing.

2. **Fast Path for Non-String Inputs**:
   - The updated version includes a check for non-string inputs using `not isinstance(library_fullname, str)`. If the input isn't a string, it quickly converts it to a string and returns it, saving unnecessary computations.

3. **Fast Path for Packages Without '@'**:
   - The line `if "@" not in library_fullname: return library_fullname` ensures that for package names without a version (e.g., `"foo"`), the function skips further processing and directly returns the input.

4. **Special Case Handling**:
   - The new implementation handles edge cases like `@library@version` (no slash but starts with `@`) and scoped packages (`@scope/package@version`) more efficiently with targeted checks and substring operations.

5. **Early Returns**:
   - The updated implementation introduces multiple early return paths, reducing the need for deeper logic or unnecessary processing.

In contrast, the old implementation processes all inputs uniformly, even if they don't contain a version specifier or don't require parsing. The new implementation is faster because it avoids unnecessary string operations and adds targeted checks for common scenarios.
created_at: 2025-04-18T04:30:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -643,18 +643,69 @@ def format_ref(ref: str) -> str:
     return f"ref_{clean_ref}"
 
 
-def format_library_name(library_fullname: str):
-    """Format the name of a library.
+def format_library_name(
+    library_fullname: str | dict[str, Any],
+) -> str:
+    """Format the name of a library, removing version information.
+
+    Handles npm package formats including scoped packages (@org/package).
 
     Args:
-        library_fullname: The fullname of the library.
+        library_fullname: The library identifier, either as:
+            - string: package name with optional version (e.g., "foo", "@bar/baz", "foo@1.2.3")
+            - dict: with a "name" key (e.g., {"name": "@bar/baz"})
 
     Returns:
-        The name without the @version if it was part of the name
+        The normalized package name without version information
+
+    Raises:
+        ValueError: If the provided dictionary does not have a 'name' key.
     """
+    # Extract name from dictionary if needed
+    if isinstance(library_fullname, dict):
+        if "name" not in library_fullname:
+            raise ValueError("Dictionary input must have a 'name' key")
+        library_fullname = library_fullname["name"]
+
+    # Fast path for non-string inputs
+    if not isinstance(library_fullname, str):
+        return str(library_fullname)
+
+    # Fast path for URLs
     if library_fullname.startswith("https://"):
         return library_fullname
-    lib, at, version = library_fullname.rpartition("@")
+
+    # Fast path for packages without @ symbol
+    if "@" not in library_fullname:
+        return library_fullname
+
+    # Handle edge case: @library@version (no slash but starts with @)
+    if library_fullname.startswith("@") and "/" not in library_fullname:
+        # Check for a second @ symbol
+        second_at_index = library_fullname.find("@", 1)
+        if second_at_index != -1:
path: reflex/utils/format.py
user: login: adhami3310
body: That's not how Python works. A singular operation is more likely to be faster than a multitude of operations that do the same thing.
created_at: 2025-04-18T04:32:19Z
author_association: MEMBER
diff_hunk: @@ -643,18 +643,69 @@ def format_ref(ref: str) -> str:
     return f"ref_{clean_ref}"
 
 
-def format_library_name(library_fullname: str):
-    """Format the name of a library.
+def format_library_name(
+    library_fullname: str | dict[str, Any],
+) -> str:
+    """Format the name of a library, removing version information.
+
+    Handles npm package formats including scoped packages (@org/package).
 
     Args:
-        library_fullname: The fullname of the library.
+        library_fullname: The library identifier, either as:
+            - string: package name with optional version (e.g., "foo", "@bar/baz", "foo@1.2.3")
+            - dict: with a "name" key (e.g., {"name": "@bar/baz"})
 
     Returns:
-        The name without the @version if it was part of the name
+        The normalized package name without version information
+
+    Raises:
+        ValueError: If the provided dictionary does not have a 'name' key.
     """
+    # Extract name from dictionary if needed
+    if isinstance(library_fullname, dict):
+        if "name" not in library_fullname:
+            raise ValueError("Dictionary input must have a 'name' key")
+        library_fullname = library_fullname["name"]
+
+    # Fast path for non-string inputs
+    if not isinstance(library_fullname, str):
+        return str(library_fullname)
+
+    # Fast path for URLs
     if library_fullname.startswith("https://"):
         return library_fullname
-    lib, at, version = library_fullname.rpartition("@")
+
+    # Fast path for packages without @ symbol
+    if "@" not in library_fullname:
+        return library_fullname
+
+    # Handle edge case: @library@version (no slash but starts with @)
+    if library_fullname.startswith("@") and "/" not in library_fullname:
+        # Check for a second @ symbol
+        second_at_index = library_fullname.find("@", 1)
+        if second_at_index != -1:
path: reflex/utils/format.py
user: login: itsmeadarsh2008
body: > That's not how Python works. A singular operation is more likely to be faster than a multitude of operations that do the same thing.

Yup. I was thinking the same thing while replying. I felt strange when Claude 3.7 Sonnet (Github Copilot) replied with this, when I cross checked. 
created_at: 2025-04-18T04:36:38Z
author_association: CONTRIBUTOR

PR comments:
user: login: itsmeadarsh2008
created_at: 2025-04-13T11:13:35Z
body: @adhami3310 Do we need any tests? I have fixed the tailwind plugins installation too.

You can see styles working with HeroUI
![image](https://github.com/user-attachments/assets/89b529d1-084b-4c67-b519-066192a98984)

author_association: CONTRIBUTOR
user: login: codspeed-hq[bot]
created_at: 2025-04-13T11:32:19Z
body: <!-- __CODSPEED_PERFORMANCE_REPORT_COMMENT__ -->
<!-- __CODSPEED_INSTRUMENTATION_PERFORMANCE_REPORT_COMMENT__ -->

## [CodSpeed Performance Report](https://codspeed.io/reflex-dev/reflex/branches/itsmeadarsh2008%3Apatch-1)

### Merging #5121 will **not alter performance**

<sub>Comparing <code>itsmeadarsh2008:patch-1</code> (dd3c7e1) with <code>main</code> (642233b)</sub>



### Summary

`✅ 12` untouched benchmarks  




author_association: NONE
user: login: itsmeadarsh2008
created_at: 2025-04-13T12:57:55Z
body: @adhami3310 It should work now.
author_association: CONTRIBUTOR
user: login: adhami3310
created_at: 2025-04-14T18:09:49Z
body: looks good! we should have a test for it and otherwise we can merge it in
author_association: MEMBER
user: login: itsmeadarsh2008
created_at: 2025-04-15T09:49:37Z
body: @adhami3310 I have added 2 test cases and rewrote `format_full_name` function to handle dict types.
author_association: CONTRIBUTOR
user: login: adhami3310
created_at: 2025-04-15T19:58:25Z
body: > rewrote `format_full_name` function to handle dict types.

why? i'm not sure i can get the advantage here
author_association: MEMBER
user: login: itsmeadarsh2008
created_at: 2025-04-16T07:36:38Z
body: > > rewrote `format_full_name` function to handle dict types.
> 
> why? i'm not sure i can get the advantage here

@adhami3310 The test cases were failing because the function `format_full_name` was taking str type for formatting, and I modified the function to handle with dict types, i.e. when something like `{"name": "foo"}` is passed, it would refer to the name key of the dictionary. This is for formatting package names from strings as well as dictionaries. Also, I rewrote the structure of the function to improve performance while formatting.
author_association: CONTRIBUTOR
user: login: itsmeadarsh2008
created_at: 2025-04-16T18:35:07Z
body: @adhami3310, why are integration tests failing? I have run it locally, it is running perfectly fine. Most of them are passed, except for the reflex web and Redis (I used to run the same pytest command in CONTRIBUTING.md), which I see here. I don't know what to do next.
author_association: CONTRIBUTOR
user: login: adhami3310
created_at: 2025-04-16T18:50:39Z
body: reflex web does something like
```py
{
  plugins: ["@tailwindcss/typography", "tailwindcss-radix"],
  darkMode: "class",
  theme: {
    extend: {
      colors: {
        gray: {
          1: "var(--gray-1)",
          2: "var(--gray-2)",
          3: "var(--gray-3)",
          4: "var(--gray-4)",
          5: "var(--gray-5)",
          6: "var(--gray-6)",
          7: "var(--gray-7)",
          8: "var(--gray-8)",
          9: "var(--gray-9)",
          10: "var(--gray-10)",
          11: "var(--gray-11)",
          12: "var(--gray-12)",
        }
      },
    },
  },
}
```
author_association: MEMBER
user: login: itsmeadarsh2008
created_at: 2025-04-17T10:03:12Z
body: @adhami3310 I have added a quickfix to jinja template. I think it should work (hopefully). If succeed we can merge it.

I have written the jinja template, it works perfectly (when I used reflex-web's tailwind config) but remains generates an unformatted tailwind config in `.web/` I think that's shouldn't create a noise about readability issues (not minified)
author_association: CONTRIBUTOR
user: login: itsmeadarsh2008
created_at: 2025-04-17T18:19:55Z
body: @adhami3310 I think issue is resolved now; pre-commit was the only failing check which I have fixed now. You can merge right in.
author_association: CONTRIBUTOR
user: login: itsmeadarsh2008
created_at: 2025-04-18T06:20:36Z
body: @adhami3310 I went back to original with dict support.
author_association: CONTRIBUTOR
user: login: itsmeadarsh2008
created_at: 2025-04-18T18:25:54Z
body: @adhami3310 The checks are failing because of webpack errors.
author_association: CONTRIBUTOR
user: login: benedikt-bartscher
created_at: 2025-04-18T18:28:16Z
body: > @adhami3310 The checks are failing because of webpack errors.

https://github.com/reflex-dev/reflex/pull/5148  
https://github.com/radix-ui/primitives/issues/3485
author_association: CONTRIBUTOR
user: login: itsmeadarsh2008
created_at: 2025-04-19T05:55:03Z
body: thanks! Are you going to ship before tailwind v4 or along with tailwind v4?
author_association: CONTRIBUTOR