浏览代码

move tailwind to its own module (#5169)

* move tailwind to its own module

* mkdir if it's missing

* fix tests

* add comment for config

* woops

* fix the test

* refactor a bit of code

* typos!

Co-authored-by: Masen Furer <m_github@0x26.net>

* i have so much typos

* deprecate inferring tailwind

* update base pyi

* fix check for once plugin is there

* empty

* do things in a slightly more solid way

* 0.7.13

* add v4 plugin

* fix imports

* fix tests

* fix the tests

* we don't need to repeat this guy

---------

Co-authored-by: Masen Furer <m_github@0x26.net>
Khaleel Al-Adhami 1 天之前
父节点
当前提交
091c90e783

+ 2 - 2
pyi_hashes.json

@@ -1,5 +1,5 @@
 {
-  "reflex/__init__.pyi": "8a6d2350e96659846436792a5c7b772b",
+  "reflex/__init__.pyi": "d7767c4fe815246a4409359da60aac25",
   "reflex/components/__init__.pyi": "76ba0a12cd3a7ba5ab6341a3ae81551f",
   "reflex/components/base/__init__.pyi": "e9aaf47be1e1977eacee97b880c8f7de",
   "reflex/components/base/app_wrap.pyi": "1d0e224e2d4b0538b19c0a038284e9b2",
@@ -56,7 +56,7 @@
   "reflex/components/radix/primitives/progress.pyi": "98b4add410a80a353ab503ad577169c2",
   "reflex/components/radix/primitives/slider.pyi": "573837a7d8d90deaf57c911faffed254",
   "reflex/components/radix/themes/__init__.pyi": "a15f9464ad99f248249ffa8e6deea4cf",
-  "reflex/components/radix/themes/base.pyi": "1f0740d3165100c24e6bb4792aa81571",
+  "reflex/components/radix/themes/base.pyi": "526db93a3f52bb00ad220f8744eba797",
   "reflex/components/radix/themes/color_mode.pyi": "f7515dccd1e315dc28a3cbbe2eabe7ff",
   "reflex/components/radix/themes/components/__init__.pyi": "87bb9ffff641928562da1622d2ca5993",
   "reflex/components/radix/themes/components/alert_dialog.pyi": "9f19bcdb4588a7f76596d142a0ac0950",

+ 1 - 0
reflex/.templates/jinja/app/rxconfig.py.jinja2

@@ -2,4 +2,5 @@ import reflex as rx
 
 config = rx.Config(
     app_name="{{ app_name }}",
+    plugins=[rx.plugins.TailwindV3Plugin()],
 )

+ 0 - 66
reflex/.templates/jinja/web/tailwind.config.js.jinja2

@@ -1,66 +0,0 @@
-{# 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 %}
-  ]
-};

+ 0 - 1
reflex/.templates/web/postcss.config.js

@@ -1,7 +1,6 @@
 module.exports = {
   plugins: {
     "postcss-import": {},
-    tailwindcss: {},
     autoprefixer: {},
   },
 };

+ 0 - 6
reflex/.templates/web/styles/tailwind.css

@@ -1,6 +0,0 @@
-@import "tailwindcss/base";
-
-@import "@radix-ui/themes/styles.css";
-
-@tailwind components;
-@tailwind utilities;

+ 1 - 0
reflex/__init__.py

@@ -361,6 +361,7 @@ _SUBMODULES: set[str] = {
     "vars",
     "config",
     "compiler",
+    "plugins",
 }
 _SUBMOD_ATTRS: dict = _MAPPING
 getattr, __dir__, __all__ = lazy_loader.attach(

+ 78 - 25
reflex/app.py

@@ -18,7 +18,7 @@ from datetime import datetime
 from pathlib import Path
 from timeit import default_timer as timer
 from types import SimpleNamespace
-from typing import TYPE_CHECKING, Any, BinaryIO, get_args, get_type_hints
+from typing import TYPE_CHECKING, Any, BinaryIO, ParamSpec, get_args, get_type_hints
 
 from fastapi import FastAPI
 from rich.progress import MofNCompleteColumn, Progress, TimeElapsedColumn
@@ -314,6 +314,9 @@ class UnevaluatedPage:
         )
 
 
+P = ParamSpec("P")
+
+
 @dataclasses.dataclass()
 class App(MiddlewareMixin, LifespanMixin):
     """The main Reflex app that encapsulates the backend and frontend.
@@ -620,10 +623,12 @@ class App(MiddlewareMixin, LifespanMixin):
         compile_future = concurrent.futures.ThreadPoolExecutor(max_workers=1).submit(
             self._compile
         )
-        compile_future.add_done_callback(
+
+        def callback(f: concurrent.futures.Future):
             # Force background compile errors to print eagerly
-            lambda f: f.result()
-        )
+            return f.result()
+
+        compile_future.add_done_callback(callback)
         # Wait for the compile to finish to ensure all optional endpoints are mounted.
         compile_future.result()
 
@@ -1029,11 +1034,6 @@ class App(MiddlewareMixin, LifespanMixin):
         frontend_packages = get_config().frontend_packages
         _frontend_packages = []
         for package in frontend_packages:
-            if package in (get_config().tailwind or {}).get("plugins", []):
-                console.warn(
-                    f"Tailwind packages are inferred from 'plugins', remove `{package}` from `frontend_packages`"
-                )
-                continue
             if package in page_imports:
                 console.warn(
                     f"React packages and their dependencies are inferred from Component.library and Component.lib_dependencies, remove `{package}` from `frontend_packages`"
@@ -1166,6 +1166,7 @@ class App(MiddlewareMixin, LifespanMixin):
 
         Raises:
             ReflexRuntimeError: When any page uses state, but no rx.State subclass is defined.
+            FileNotFoundError: When a plugin requires a file that does not exist.
         """
         from reflex.utils.exceptions import ReflexRuntimeError
 
@@ -1380,10 +1381,20 @@ class App(MiddlewareMixin, LifespanMixin):
 
         ExecutorSafeFunctions.STATE = self._state
 
-        with console.timing("Compile to Javascript"), executor as executor:
-            result_futures: list[concurrent.futures.Future[tuple[str, str]]] = []
+        modify_files_tasks: list[tuple[str, str, Callable[[str], str]]] = []
 
-            def _submit_work(fn: Callable[..., tuple[str, str]], *args, **kwargs):
+        with console.timing("Compile to Javascript"), executor as executor:
+            result_futures: list[
+                concurrent.futures.Future[
+                    list[tuple[str, str]] | tuple[str, str] | None
+                ]
+            ] = []
+
+            def _submit_work(
+                fn: Callable[P, list[tuple[str, str]] | tuple[str, str] | None],
+                *args: P.args,
+                **kwargs: P.kwargs,
+            ):
                 f = executor.submit(fn, *args, **kwargs)
                 f.add_done_callback(lambda _: progress.advance(task))
                 result_futures.append(f)
@@ -1401,20 +1412,26 @@ class App(MiddlewareMixin, LifespanMixin):
             # Compile the theme.
             _submit_work(compile_theme, self.style)
 
-            # Compile the Tailwind config.
-            if config.tailwind is not None:
-                config.tailwind["content"] = config.tailwind.get(
-                    "content", constants.Tailwind.CONTENT
+            for plugin in config.plugins:
+                plugin.pre_compile(
+                    add_save_task=_submit_work,
+                    add_modify_task=(
+                        lambda *args, plugin=plugin: modify_files_tasks.append(
+                            (
+                                plugin.__class__.__module__ + plugin.__class__.__name__,
+                                *args,
+                            )
+                        )
+                    ),
                 )
-                _submit_work(compiler.compile_tailwind, config.tailwind)
-            else:
-                _submit_work(compiler.remove_tailwind_from_postcss)
 
             # Wait for all compilation tasks to complete.
-            compile_results.extend(
-                future.result()
-                for future in concurrent.futures.as_completed(result_futures)
-            )
+            for future in concurrent.futures.as_completed(result_futures):
+                if (result := future.result()) is not None:
+                    if isinstance(result, list):
+                        compile_results.extend(result)
+                    else:
+                        compile_results.append(result)
 
         app_root = self._app_root(app_wrappers=app_wrappers)
 
@@ -1481,9 +1498,45 @@ class App(MiddlewareMixin, LifespanMixin):
                     # Remove pages that are no longer in the app.
                     p.unlink()
 
+        output_mapping: dict[Path, str] = {}
+        for output_path, code in compile_results:
+            path = compiler_utils.resolve_path_of_web_dir(output_path)
+            if path in output_mapping:
+                console.warn(
+                    f"Path {path} has two different outputs. The first one will be used."
+                )
+            else:
+                output_mapping[path] = code
+
+        for plugin in config.plugins:
+            for static_file_path, content in plugin.get_static_assets():
+                path = compiler_utils.resolve_path_of_web_dir(static_file_path)
+                if path in output_mapping:
+                    console.warn(
+                        f"Plugin {plugin.__class__.__name__} is trying to write to {path} but it already exists. The plugin file will be ignored."
+                    )
+                else:
+                    output_mapping[path] = (
+                        content.decode("utf-8")
+                        if isinstance(content, bytes)
+                        else content
+                    )
+
+        for plugin_name, file_path, modify_fn in modify_files_tasks:
+            path = compiler_utils.resolve_path_of_web_dir(file_path)
+            file_content = output_mapping.get(path)
+            if file_content is None:
+                if path.exists():
+                    file_content = path.read_text()
+                else:
+                    raise FileNotFoundError(
+                        f"Plugin {plugin_name} is trying to modify {path} but it does not exist."
+                    )
+            output_mapping[path] = modify_fn(file_content)
+
         with console.timing("Write to Disk"):
-            for output_path, code in compile_results:
-                compiler_utils.write_page(output_path, code)
+            for output_path, code in output_mapping.items():
+                compiler_utils.write_file(output_path, code)
 
     def _write_stateful_pages_marker(self):
         """Write list of routes that create dynamic states for the backend to use later."""

+ 9 - 60
reflex/compiler/compiler.py

@@ -223,6 +223,9 @@ def _validate_stylesheet(stylesheet_full_path: Path, assets_app_path: Path) -> N
         )
 
 
+RADIX_THEMES_STYLESHEET = "@radix-ui/themes/styles.css"
+
+
 def _compile_root_stylesheet(stylesheets: list[str]) -> str:
     """Compile the root stylesheet.
 
@@ -235,12 +238,12 @@ def _compile_root_stylesheet(stylesheets: list[str]) -> str:
     Raises:
         FileNotFoundError: If a specified stylesheet in assets directory does not exist.
     """
-    # Add tailwind css if enabled.
-    sheets = (
-        [constants.Tailwind.ROOT_STYLE_PATH]
-        if get_config().tailwind is not None
-        else []
-    )
+    # Add stylesheets from plugins.
+    sheets = [RADIX_THEMES_STYLESHEET] + [
+        sheet
+        for plugin in get_config().plugins
+        for sheet in plugin.get_stylesheet_paths()
+    ]
 
     failed_to_import_sass = False
     assets_app_path = Path.cwd() / constants.Dirs.APP_ASSETS
@@ -451,22 +454,6 @@ def _compile_stateful_components(
     )
 
 
-def _compile_tailwind(
-    config: dict,
-) -> str:
-    """Compile the Tailwind config.
-
-    Args:
-        config: The Tailwind config.
-
-    Returns:
-        The compiled Tailwind config.
-    """
-    return templates.TAILWIND_CONFIG.render(
-        **config,
-    )
-
-
 def compile_document_root(
     head_components: list[Component],
     html_lang: str | None = None,
@@ -613,44 +600,6 @@ def compile_stateful_components(
     return output_path, code, page_components
 
 
-def compile_tailwind(
-    config: dict,
-):
-    """Compile the Tailwind config.
-
-    Args:
-        config: The Tailwind config.
-
-    Returns:
-        The compiled Tailwind config.
-    """
-    # Get the path for the output file.
-    output_path = str((get_web_dir() / constants.Tailwind.CONFIG).absolute())
-
-    # Compile the config.
-    code = _compile_tailwind(config)
-    return output_path, code
-
-
-def remove_tailwind_from_postcss() -> tuple[str, str]:
-    """If tailwind is not to be used, remove it from postcss.config.js.
-
-    Returns:
-        The path and code of the compiled postcss.config.js.
-    """
-    # Get the path for the output file.
-    output_path = str(get_web_dir() / constants.Dirs.POSTCSS_JS)
-
-    code = [
-        line
-        for line in Path(output_path).read_text().splitlines(keepends=True)
-        if "tailwindcss: " not in line
-    ]
-
-    # Compile the config.
-    return output_path, "".join(code)
-
-
 def purge_web_pages_dir():
     """Empty out .web/pages directory."""
     if not is_prod_mode() and environment.REFLEX_PERSIST_WEB_DIR.get():

+ 12 - 3
reflex/compiler/templates.py

@@ -98,6 +98,18 @@ def get_template(name: str) -> Template:
     return ReflexJinjaEnvironment().get_template(name=name)
 
 
+def from_string(source: str) -> Template:
+    """Get render function that work with a template.
+
+    Args:
+        source: The template source.
+
+    Returns:
+        A render function.
+    """
+    return ReflexJinjaEnvironment().from_string(source=source)
+
+
 # Template for the Reflex config file.
 RXCONFIG = get_template("app/rxconfig.py.jinja2")
 
@@ -113,9 +125,6 @@ THEME = get_template("web/utils/theme.js.jinja2")
 # Template for the context file.
 CONTEXT = get_template("web/utils/context.js.jinja2")
 
-# Template for Tailwind config.
-TAILWIND_CONFIG = get_template("web/tailwind.config.js.jinja2")
-
 # Template to render a component tag.
 COMPONENT = get_template("web/pages/component.js.jinja2")
 

+ 18 - 2
reflex/compiler/utils.py

@@ -500,7 +500,23 @@ def add_meta(
     return page
 
 
-def write_page(path: str | Path, code: str):
+def resolve_path_of_web_dir(path: str | Path) -> Path:
+    """Get the path under the web directory.
+
+    Args:
+        path: The path to get. It can be a relative or absolute path.
+
+    Returns:
+        The path under the web directory.
+    """
+    path = Path(path)
+    web_dir = get_web_dir()
+    if path.is_relative_to(web_dir):
+        return path.absolute()
+    return (web_dir / path).absolute()
+
+
+def write_file(path: str | Path, code: str):
     """Write the given code to the given path.
 
     Args:
@@ -508,7 +524,7 @@ def write_page(path: str | Path, code: str):
         code: The code to write.
     """
     path = Path(path)
-    path_ops.mkdir(path.parent)
+    path.parent.mkdir(parents=True, exist_ok=True)
     if path.exists() and path.read_text(encoding="utf-8") == code:
         return
     path.write_text(code, encoding="utf-8")

+ 1 - 10
reflex/components/radix/themes/base.py

@@ -7,7 +7,6 @@ from typing import Any, ClassVar, Literal
 from reflex.components import Component
 from reflex.components.core.breakpoints import Responsive
 from reflex.components.tags import Tag
-from reflex.config import get_config
 from reflex.utils.imports import ImportDict, ImportVar
 from reflex.vars.base import Var
 
@@ -241,17 +240,9 @@ class Theme(RadixThemesComponent):
         Returns:
             The import dict.
         """
-        _imports: ImportDict = {
+        return {
             "$/utils/theme.js": [ImportVar(tag="theme", is_default=True)],
         }
-        if get_config().tailwind is None:
-            # When tailwind is disabled, import the radix-ui styles directly because they will
-            # not be included in the tailwind.css file.
-            _imports[""] = ImportVar(
-                tag="@radix-ui/themes/styles.css",
-                install=False,
-            )
-        return _imports
 
     def _render(self, props: dict[str, Any] | None = None) -> Tag:
         tag = super()._render(props)

+ 21 - 0
reflex/config.py

@@ -35,6 +35,7 @@ import pydantic.v1 as pydantic
 from reflex import constants
 from reflex.base import Base
 from reflex.constants.base import LogLevel
+from reflex.plugins import Plugin, TailwindV3Plugin, TailwindV4Plugin
 from reflex.utils import console
 from reflex.utils.exceptions import ConfigError, EnvironmentVarValueError
 from reflex.utils.types import (
@@ -895,6 +896,9 @@ class Config(Base):
     # Extra overlay function to run after the app is built. Formatted such that `from path_0.path_1... import path[-1]`, and calling it with no arguments would work. For example, "reflex.components.moment.moment".
     extra_overlay_function: str | None = None
 
+    # List of plugins to use in the app.
+    plugins: list[Plugin] = []
+
     _prefixes: ClassVar[list[str]] = ["REFLEX_"]
 
     def __init__(self, *args, **kwargs):
@@ -939,6 +943,23 @@ class Config(Base):
         self._non_default_attributes.update(kwargs)
         self._replace_defaults(**kwargs)
 
+        if self.tailwind is not None and not any(
+            isinstance(plugin, (TailwindV3Plugin, TailwindV4Plugin))
+            for plugin in self.plugins
+        ):
+            console.deprecate(
+                "Inferring tailwind usage",
+                reason="""
+
+If you are using tailwind, add `rx.plugins.TailwindV3Plugin()` to the `plugins=[]` in rxconfig.py.
+
+If you are not using tailwind, set `tailwind` to `None` in rxconfig.py.""",
+                deprecation_version="0.7.13",
+                removal_version="0.8.0",
+                dedupe=True,
+            )
+            self.plugins.append(TailwindV3Plugin())
+
         if (
             self.state_manager_mode == constants.StateManagerMode.REDIS
             and not self.redis_url

+ 0 - 2
reflex/constants/__init__.py

@@ -59,7 +59,6 @@ from .route import (
     RouteVar,
 )
 from .state import StateManagerMode
-from .style import Tailwind
 
 __all__ = [
     "ALEMBIC_CONFIG",
@@ -115,6 +114,5 @@ __all__ = [
     "RouteVar",
     "SocketEvent",
     "StateManagerMode",
-    "Tailwind",
     "Templates",
 ]

+ 0 - 16
reflex/constants/style.py

@@ -1,16 +0,0 @@
-"""Style constants."""
-
-from types import SimpleNamespace
-
-
-class Tailwind(SimpleNamespace):
-    """Tailwind constants."""
-
-    # The Tailwindcss version
-    VERSION = "tailwindcss@3.4.17"
-    # The Tailwind config.
-    CONFIG = "tailwind.config.js"
-    # Default Tailwind content paths
-    CONTENT = ["./pages/**/*.{js,ts,jsx,tsx}", "./utils/**/*.{js,ts,jsx,tsx}"]
-    # Relative tailwind style path to root stylesheet in Dirs.STYLES.
-    ROOT_STYLE_PATH = "./tailwind.css"

+ 7 - 0
reflex/plugins/__init__.py

@@ -0,0 +1,7 @@
+"""Reflex Plugin System."""
+
+from .base import CommonContext as CommonContext
+from .base import Plugin as Plugin
+from .base import PreCompileContext as PreCompileContext
+from .tailwind_v3 import Plugin as TailwindV3Plugin
+from .tailwind_v4 import Plugin as TailwindV4Plugin

+ 101 - 0
reflex/plugins/base.py

@@ -0,0 +1,101 @@
+"""Base class for all plugins."""
+
+from collections.abc import Callable, Sequence
+from pathlib import Path
+from typing import ParamSpec, Protocol, TypedDict
+
+from typing_extensions import Unpack
+
+
+class CommonContext(TypedDict):
+    """Common context for all plugins."""
+
+
+P = ParamSpec("P")
+
+
+class AddTaskProtcol(Protocol):
+    """Protocol for adding a task to the pre-compile context."""
+
+    def __call__(
+        self,
+        task: Callable[P, list[tuple[str, str]] | tuple[str, str] | None],
+        /,
+        *args: P.args,
+        **kwargs: P.kwargs,
+    ) -> None:
+        """Add a task to the pre-compile context.
+
+        Args:
+            task: The task to add.
+            args: The arguments to pass to the task
+            kwargs: The keyword arguments to pass to the task
+        """
+
+
+class PreCompileContext(CommonContext):
+    """Context for pre-compile hooks."""
+
+    add_save_task: AddTaskProtcol
+    add_modify_task: Callable[[str, Callable[[str], str]], None]
+
+
+class Plugin:
+    """Base class for all plugins."""
+
+    def get_frontend_development_dependencies(
+        self, **context: Unpack[CommonContext]
+    ) -> list[str] | set[str] | tuple[str, ...]:
+        """Get the NPM packages required by the plugin for development.
+
+        Args:
+            context: The context for the plugin.
+
+        Returns:
+            A list of packages required by the plugin for development.
+        """
+        return []
+
+    def get_frontend_dependencies(
+        self, **context: Unpack[CommonContext]
+    ) -> list[str] | set[str] | tuple[str, ...]:
+        """Get the NPM packages required by the plugin.
+
+        Args:
+            context: The context for the plugin.
+
+        Returns:
+            A list of packages required by the plugin.
+        """
+        return []
+
+    def get_static_assets(
+        self, **context: Unpack[CommonContext]
+    ) -> Sequence[tuple[Path, str | bytes]]:
+        """Get the static assets required by the plugin.
+
+        Args:
+            context: The context for the plugin.
+
+        Returns:
+            A list of static assets required by the plugin.
+        """
+        return []
+
+    def get_stylesheet_paths(self, **context: Unpack[CommonContext]) -> Sequence[str]:
+        """Get the paths to the stylesheets required by the plugin relative to the styles directory.
+
+        Args:
+            context: The context for the plugin.
+
+        Returns:
+            A list of paths to the stylesheets required by the plugin.
+        """
+        return []
+
+    def pre_compile(self, **context: Unpack[PreCompileContext]) -> None:
+        """Called before the compilation of the plugin.
+
+        Args:
+            context: The context for the plugin.
+        """

+ 255 - 0
reflex/plugins/tailwind_v3.py

@@ -0,0 +1,255 @@
+"""Base class for all plugins."""
+
+from pathlib import Path
+from types import SimpleNamespace
+
+from reflex.constants.base import Dirs
+from reflex.constants.compiler import Ext, PageNames
+from reflex.plugins.base import Plugin as PluginBase
+from reflex.utils.decorator import once
+
+
+class Constants(SimpleNamespace):
+    """Tailwind constants."""
+
+    # The Tailwindcss version
+    VERSION = "tailwindcss@3.4.17"
+    # The Tailwind config.
+    CONFIG = "tailwind.config.js"
+    # Default Tailwind content paths
+    CONTENT = ["./pages/**/*.{js,ts,jsx,tsx}", "./utils/**/*.{js,ts,jsx,tsx}"]
+    # Relative tailwind style path to root stylesheet in Dirs.STYLES.
+    ROOT_STYLE_PATH = "./tailwind.css"
+
+    # Content of the style content.
+    ROOT_STYLE_CONTENT = """
+@import "tailwindcss/base";
+
+@import url('{radix_url}');
+
+@tailwind components;
+@tailwind utilities;
+"""
+
+    # The default tailwind css.
+    TAILWIND_CSS = "@import url('./tailwind.css');"
+
+
+@once
+def tailwind_config_js_template():
+    """Get the Tailwind config template.
+
+    Returns:
+        The Tailwind config template.
+    """
+    from reflex.compiler.templates import from_string
+
+    source = r"""
+{# 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 %}
+  ]
+};
+"""
+
+    return from_string(source)
+
+
+def compile_config(
+    config: dict,
+):
+    """Compile the Tailwind config.
+
+    Args:
+        config: The Tailwind config.
+
+    Returns:
+        The compiled Tailwind config.
+    """
+    return Constants.CONFIG, tailwind_config_js_template().render(
+        **config,
+    )
+
+
+def compile_root_style():
+    """Compile the Tailwind root style.
+
+    Returns:
+        The compiled Tailwind root style.
+    """
+    from reflex.compiler.compiler import RADIX_THEMES_STYLESHEET
+
+    return str(
+        Path(Dirs.STYLES) / Constants.ROOT_STYLE_PATH
+    ), Constants.ROOT_STYLE_CONTENT.format(
+        radix_url=RADIX_THEMES_STYLESHEET,
+    )
+
+
+def _index_of_element_that_has(haystack: list[str], needle: str) -> int | None:
+    return next(
+        (i for i, line in enumerate(haystack) if needle in line),
+        None,
+    )
+
+
+def add_tailwind_to_postcss_config(postcss_file_content: str) -> str:
+    """Add tailwind to the postcss config.
+
+    Args:
+        postcss_file_content: The content of the postcss config file.
+
+    Returns:
+        The modified postcss config file content.
+    """
+    from reflex.constants import Dirs
+
+    postcss_file_lines = postcss_file_content.splitlines()
+
+    if _index_of_element_that_has(postcss_file_lines, "tailwindcss") is not None:
+        return postcss_file_content
+
+    line_with_postcss_plugins = _index_of_element_that_has(
+        postcss_file_lines, "plugins"
+    )
+    if not line_with_postcss_plugins:
+        print(  # noqa: T201
+            f"Could not find line with 'plugins' in {Dirs.POSTCSS_JS}. "
+            "Please make sure the file exists and is valid."
+        )
+        return postcss_file_content
+
+    postcss_import_line = _index_of_element_that_has(
+        postcss_file_lines, '"postcss-import"'
+    )
+    postcss_file_lines.insert(
+        (postcss_import_line or line_with_postcss_plugins) + 1, "tailwindcss: {},"
+    )
+
+    return "\n".join(postcss_file_lines)
+
+
+def add_tailwind_to_css_file(css_file_content: str) -> str:
+    """Add tailwind to the css file.
+
+    Args:
+        css_file_content: The content of the css file.
+
+    Returns:
+        The modified css file content.
+    """
+    from reflex.compiler.compiler import RADIX_THEMES_STYLESHEET
+
+    if Constants.TAILWIND_CSS.splitlines()[0] in css_file_content:
+        return css_file_content
+    if RADIX_THEMES_STYLESHEET not in css_file_content:
+        print(  # noqa: T201
+            f"Could not find line with '{RADIX_THEMES_STYLESHEET}' in {Dirs.STYLES}. "
+            "Please make sure the file exists and is valid."
+        )
+        return css_file_content
+    return css_file_content.replace(
+        f"@import url('{RADIX_THEMES_STYLESHEET}');",
+        Constants.TAILWIND_CSS,
+    )
+
+
+class Plugin(PluginBase):
+    """Plugin for Tailwind CSS."""
+
+    def get_frontend_development_dependencies(self, **context) -> list[str]:
+        """Get the packages required by the plugin.
+
+        Args:
+            **context: The context for the plugin.
+
+        Returns:
+            A list of packages required by the plugin.
+        """
+        from reflex.config import get_config
+
+        config = get_config()
+        return [
+            plugin if isinstance(plugin, str) else plugin.get("name")
+            for plugin in (config.tailwind or {}).get("plugins", [])
+        ] + [Constants.VERSION]
+
+    def pre_compile(self, **context):
+        """Pre-compile the plugin.
+
+        Args:
+            context: The context for the plugin.
+        """
+        from reflex.config import get_config
+
+        config = get_config().tailwind or {}
+
+        config["content"] = config.get("content", Constants.CONTENT)
+        context["add_save_task"](compile_config, config)
+        context["add_save_task"](compile_root_style)
+        context["add_modify_task"](Dirs.POSTCSS_JS, add_tailwind_to_postcss_config)
+        context["add_modify_task"](
+            str(Path(Dirs.STYLES) / (PageNames.STYLESHEET_ROOT + Ext.CSS)),
+            add_tailwind_to_css_file,
+        )

+ 257 - 0
reflex/plugins/tailwind_v4.py

@@ -0,0 +1,257 @@
+"""Base class for all plugins."""
+
+from pathlib import Path
+from types import SimpleNamespace
+
+from reflex.constants.base import Dirs
+from reflex.constants.compiler import Ext, PageNames
+from reflex.plugins.base import Plugin as PluginBase
+from reflex.utils.decorator import once
+
+
+class Constants(SimpleNamespace):
+    """Tailwind constants."""
+
+    # The Tailwindcss version
+    VERSION = "tailwindcss@4.1.7"
+    # The Tailwind config.
+    CONFIG = "tailwind.config.js"
+    # Default Tailwind content paths
+    CONTENT = ["./pages/**/*.{js,ts,jsx,tsx}", "./utils/**/*.{js,ts,jsx,tsx}"]
+    # Relative tailwind style path to root stylesheet in Dirs.STYLES.
+    ROOT_STYLE_PATH = "./tailwind.css"
+
+    # Content of the style content.
+    ROOT_STYLE_CONTENT = """@layer theme, base, components, utilities;
+@import "tailwindcss/theme.css" layer(theme);
+@import "tailwindcss/preflight.css" layer(base);
+@import "{radix_url}" layer(components);
+@import "tailwindcss/utilities.css" layer(utilities);
+"""
+
+    # The default tailwind css.
+    TAILWIND_CSS = "@import url('./tailwind.css');"
+
+
+@once
+def tailwind_config_js_template():
+    """Get the Tailwind config template.
+
+    Returns:
+        The Tailwind config template.
+    """
+    from reflex.compiler.templates import from_string
+
+    source = r"""
+{# 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 %}
+  ]
+};
+"""
+
+    return from_string(source)
+
+
+def compile_config(
+    config: dict,
+):
+    """Compile the Tailwind config.
+
+    Args:
+        config: The Tailwind config.
+
+    Returns:
+        The compiled Tailwind config.
+    """
+    return Constants.CONFIG, tailwind_config_js_template().render(
+        **config,
+    )
+
+
+def compile_root_style():
+    """Compile the Tailwind root style.
+
+    Returns:
+        The compiled Tailwind root style.
+    """
+    from reflex.compiler.compiler import RADIX_THEMES_STYLESHEET
+
+    return str(
+        Path(Dirs.STYLES) / Constants.ROOT_STYLE_PATH
+    ), Constants.ROOT_STYLE_CONTENT.format(
+        radix_url=RADIX_THEMES_STYLESHEET,
+    )
+
+
+def _index_of_element_that_has(haystack: list[str], needle: str) -> int | None:
+    return next(
+        (i for i, line in enumerate(haystack) if needle in line),
+        None,
+    )
+
+
+def add_tailwind_to_postcss_config(postcss_file_content: str) -> str:
+    """Add tailwind to the postcss config.
+
+    Args:
+        postcss_file_content: The content of the postcss config file.
+
+    Returns:
+        The modified postcss config file content.
+    """
+    from reflex.constants import Dirs
+
+    postcss_file_lines = postcss_file_content.splitlines()
+
+    line_with_postcss_plugins = _index_of_element_that_has(
+        postcss_file_lines, "plugins"
+    )
+    if not line_with_postcss_plugins:
+        print(  # noqa: T201
+            f"Could not find line with 'plugins' in {Dirs.POSTCSS_JS}. "
+            "Please make sure the file exists and is valid."
+        )
+        return postcss_file_content
+
+    plugins_to_remove = ['"postcss-import"', "tailwindcss", "autoprefixer"]
+    plugins_to_add = ['"@tailwindcss/postcss"']
+
+    for plugin in plugins_to_remove:
+        plugin_index = _index_of_element_that_has(postcss_file_lines, plugin)
+        if plugin_index is not None:
+            postcss_file_lines.pop(plugin_index)
+
+    for plugin in plugins_to_add[::-1]:
+        if not _index_of_element_that_has(postcss_file_lines, plugin):
+            postcss_file_lines.insert(
+                line_with_postcss_plugins + 1, f"  {plugin}: {{}},"
+            )
+
+    return "\n".join(postcss_file_lines)
+
+
+def add_tailwind_to_css_file(css_file_content: str) -> str:
+    """Add tailwind to the css file.
+
+    Args:
+        css_file_content: The content of the css file.
+
+    Returns:
+        The modified css file content.
+    """
+    from reflex.compiler.compiler import RADIX_THEMES_STYLESHEET
+
+    if Constants.TAILWIND_CSS.splitlines()[0] in css_file_content:
+        return css_file_content
+    if RADIX_THEMES_STYLESHEET not in css_file_content:
+        print(  # noqa: T201
+            f"Could not find line with '{RADIX_THEMES_STYLESHEET}' in {Dirs.STYLES}. "
+            "Please make sure the file exists and is valid."
+        )
+        return css_file_content
+    return css_file_content.replace(
+        f"@import url('{RADIX_THEMES_STYLESHEET}');",
+        Constants.TAILWIND_CSS,
+    )
+
+
+class Plugin(PluginBase):
+    """Plugin for Tailwind CSS."""
+
+    def get_frontend_development_dependencies(self, **context) -> list[str]:
+        """Get the packages required by the plugin.
+
+        Args:
+            **context: The context for the plugin.
+
+        Returns:
+            A list of packages required by the plugin.
+        """
+        from reflex.config import get_config
+
+        config = get_config()
+        return [
+            plugin if isinstance(plugin, str) else plugin.get("name")
+            for plugin in (config.tailwind or {}).get("plugins", [])
+        ] + [Constants.VERSION, "@tailwindcss/postcss@4.1.7"]
+
+    def pre_compile(self, **context):
+        """Pre-compile the plugin.
+
+        Args:
+            context: The context for the plugin.
+        """
+        from reflex.config import get_config
+
+        config = get_config().tailwind or {}
+
+        config["content"] = config.get("content", Constants.CONTENT)
+        context["add_save_task"](compile_config, config)
+        context["add_save_task"](compile_root_style)
+        context["add_modify_task"](Dirs.POSTCSS_JS, add_tailwind_to_postcss_config)
+        context["add_modify_task"](
+            str(Path(Dirs.STYLES) / (PageNames.STYLESHEET_ROOT + Ext.CSS)),
+            add_tailwind_to_css_file,
+        )

+ 19 - 88
reflex/utils/prerequisites.py

@@ -17,7 +17,6 @@ import re
 import shutil
 import sys
 import tempfile
-import time
 import typing
 import zipfile
 from collections.abc import Callable, Sequence
@@ -40,10 +39,7 @@ from reflex.compiler import templates
 from reflex.config import Config, environment, get_config
 from reflex.utils import console, net, path_ops, processes, redir
 from reflex.utils.decorator import once
-from reflex.utils.exceptions import (
-    GeneratedCodeHasNoFunctionDefsError,
-    SystemPackageMissingError,
-)
+from reflex.utils.exceptions import SystemPackageMissingError
 from reflex.utils.format import format_library_name
 from reflex.utils.registry import get_npm_registry
 
@@ -1317,47 +1313,42 @@ def install_frontend_packages(packages: set[str], config: Config):
     primary_package_manager = install_package_managers[0]
     fallbacks = install_package_managers[1:]
 
-    processes.run_process_with_fallbacks(
-        [primary_package_manager, "install", "--legacy-peer-deps"],
+    run_package_manager = functools.partial(
+        processes.run_process_with_fallbacks,
         fallbacks=fallbacks,
         analytics_enabled=True,
-        show_status_message="Installing base frontend packages",
         cwd=get_web_dir(),
         shell=constants.IS_WINDOWS,
         env=env,
     )
 
-    if config.tailwind is not None:
-        processes.run_process_with_fallbacks(
+    run_package_manager(
+        [primary_package_manager, "install", "--legacy-peer-deps"],
+        show_status_message="Installing base frontend packages",
+    )
+
+    development_deps: set[str] = set()
+    for plugin in config.plugins:
+        development_deps.update(plugin.get_frontend_development_dependencies())
+        packages.update(plugin.get_frontend_dependencies())
+
+    if development_deps:
+        run_package_manager(
             [
                 primary_package_manager,
                 "add",
                 "--legacy-peer-deps",
                 "-d",
-                constants.Tailwind.VERSION,
-                *[
-                    plugin if isinstance(plugin, str) else plugin.get("name")
-                    for plugin in (config.tailwind or {}).get("plugins", [])
-                ],
+                *development_deps,
             ],
-            fallbacks=fallbacks,
-            analytics_enabled=True,
-            show_status_message="Installing tailwind",
-            cwd=get_web_dir(),
-            shell=constants.IS_WINDOWS,
-            env=env,
+            show_status_message="Installing frontend development dependencies",
         )
 
     # Install custom packages defined in frontend_packages
-    if len(packages) > 0:
-        processes.run_process_with_fallbacks(
+    if packages:
+        run_package_manager(
             [primary_package_manager, "add", "--legacy-peer-deps", *packages],
-            fallbacks=fallbacks,
-            analytics_enabled=True,
             show_status_message="Installing frontend packages from config and components",
-            cwd=get_web_dir(),
-            shell=constants.IS_WINDOWS,
-            env=env,
         )
 
 
@@ -1931,66 +1922,6 @@ def get_init_cli_prompt_options() -> list[Template]:
     ]
 
 
-def initialize_main_module_index_from_generation(app_name: str, generation_hash: str):
-    """Overwrite the `index` function in the main module with reflex.build generated code.
-
-    Args:
-        app_name: The name of the app.
-        generation_hash: The generation hash from reflex.build.
-
-    Raises:
-        GeneratedCodeHasNoFunctionDefsError: If the fetched code has no function definitions
-            (the refactored reflex code is expected to have at least one root function defined).
-    """
-    # Download the reflex code for the generation.
-    url = constants.Templates.REFLEX_BUILD_CODE_URL.format(
-        generation_hash=generation_hash
-    )
-    resp = net.get(url)
-    while resp.status_code == httpx.codes.SERVICE_UNAVAILABLE:
-        console.debug("Waiting for the code to be generated...")
-        time.sleep(1)
-        resp = net.get(url)
-    resp.raise_for_status()
-
-    # Determine the name of the last function, which renders the generated code.
-    defined_funcs = re.findall(r"def ([a-zA-Z_]+)\(", resp.text)
-    if not defined_funcs:
-        raise GeneratedCodeHasNoFunctionDefsError(
-            f"No function definitions found in generated code from {url!r}."
-        )
-    render_func_name = defined_funcs[-1]
-
-    def replace_content(_match: re.Match) -> str:
-        return "\n".join(
-            [
-                resp.text,
-                "",
-                "def index() -> rx.Component:",
-                f"    return {render_func_name}()",
-                "",
-                "",
-            ],
-        )
-
-    main_module_path = Path(app_name, app_name + constants.Ext.PY)
-    main_module_code = main_module_path.read_text()
-
-    main_module_code = re.sub(
-        r"def index\(\).*:\n([^\n]\s+.*\n+)+",
-        replace_content,
-        main_module_code,
-    )
-    # Make the app use light mode until flexgen enforces the conversion of
-    # tailwind colors to radix colors.
-    main_module_code = re.sub(
-        r"app\s*=\s*rx\.App\(\s*\)",
-        'app = rx.App(theme=rx.theme(color_mode="light"))',
-        main_module_code,
-    )
-    main_module_path.write_text(main_module_code)
-
-
 def format_address_width(address_width: str | None) -> int | None:
     """Cast address width to an int.
 

+ 32 - 18
tests/integration/test_tailwind.py

@@ -10,18 +10,19 @@ from reflex.testing import AppHarness
 
 PARAGRAPH_TEXT = "Tailwind Is Cool"
 PARAGRAPH_CLASS_NAME = "text-red-500"
-TEXT_RED_500_COLOR = ["rgba(239, 68, 68, 1)", "rgb(239, 68, 68)"]
+TEXT_RED_500_COLOR_v3 = ["rgba(239, 68, 68, 1)", "rgb(239, 68, 68)"]
+TEXT_RED_500_COLOR_v4 = ["oklch(0.637 0.237 25.331)"]
 
 
 def TailwindApp(
-    tailwind_disabled: bool = False,
+    tailwind_version: int = 0,
     paragraph_text: str = PARAGRAPH_TEXT,
     paragraph_class_name: str = PARAGRAPH_CLASS_NAME,
 ):
     """App with tailwind optionally disabled.
 
     Args:
-        tailwind_disabled: Whether tailwind is disabled for the app.
+        tailwind_version: Tailwind version to use. If 0, tailwind is disabled.
         paragraph_text: Text for the paragraph.
         paragraph_class_name: Tailwind class_name for the paragraph.
     """
@@ -47,49 +48,59 @@ def TailwindApp(
     stylesheet.write_text(".external { color: rgba(0, 0, 255, 0.5) }")
     app = rx.App(style={"font_family": "monospace"}, stylesheets=[stylesheet.name])
     app.add_page(index)
-    if tailwind_disabled:
+    if not tailwind_version:
         config = rx.config.get_config()
         config.tailwind = None
+        config.plugins = []
+    elif tailwind_version == 3:
+        config = rx.config.get_config()
+        config.plugins = [rx.plugins.TailwindV3Plugin()]
+    elif tailwind_version == 4:
+        config = rx.config.get_config()
+        config.plugins = [rx.plugins.TailwindV4Plugin()]
 
 
-@pytest.fixture(params=[False, True], ids=["tailwind_enabled", "tailwind_disabled"])
-def tailwind_disabled(request) -> bool:
-    """Tailwind disabled fixture.
+@pytest.fixture(
+    params=[0, 3, 4], ids=["tailwind_disabled", "tailwind_v3", "tailwind_v4"]
+)
+def tailwind_version(request) -> int:
+    """Tailwind version fixture.
 
     Args:
         request: pytest request fixture.
 
     Returns:
-        True if tailwind is disabled, False otherwise.
+        Tailwind version to use. 0 for disabled, 3 for v3, 4 for v4.
     """
     return request.param
 
 
 @pytest.fixture()
-def tailwind_app(tmp_path, tailwind_disabled) -> Generator[AppHarness, None, None]:
+def tailwind_app(tmp_path, tailwind_version) -> Generator[AppHarness, None, None]:
     """Start TailwindApp app at tmp_path via AppHarness with tailwind disabled via config.
 
     Args:
         tmp_path: pytest tmp_path fixture
-        tailwind_disabled: Whether tailwind is disabled for the app.
+        tailwind_version: Whether tailwind is disabled for the app.
 
     Yields:
         running AppHarness instance
     """
     with AppHarness.create(
         root=tmp_path,
-        app_source=functools.partial(TailwindApp, tailwind_disabled=tailwind_disabled),
-        app_name="tailwind_disabled_app" if tailwind_disabled else "tailwind_app",
+        app_source=functools.partial(TailwindApp, tailwind_version=tailwind_version),
+        app_name="tailwind_"
+        + ("disabled" if tailwind_version == 0 else str(tailwind_version)),
     ) as harness:
         yield harness
 
 
-def test_tailwind_app(tailwind_app: AppHarness, tailwind_disabled: bool):
+def test_tailwind_app(tailwind_app: AppHarness, tailwind_version: bool):
     """Test that the app can compile without tailwind.
 
     Args:
         tailwind_app: AppHarness instance.
-        tailwind_disabled: Whether tailwind is disabled for the app.
+        tailwind_version: Tailwind version to use. If 0, tailwind is disabled.
     """
     assert tailwind_app.app_instance is not None
     assert tailwind_app.backend is not None
@@ -108,12 +119,15 @@ def test_tailwind_app(tailwind_app: AppHarness, tailwind_disabled: bool):
     for p in paragraphs:
         assert tailwind_app.poll_for_content(p, exp_not_equal="") == PARAGRAPH_TEXT
         assert p.value_of_css_property("font-family") == "monospace"
-        if tailwind_disabled:
+        if not tailwind_version:
             # expect default color, not "text-red-500" from tailwind utility class
-            assert p.value_of_css_property("color") not in TEXT_RED_500_COLOR
-        else:
+            assert p.value_of_css_property("color") not in TEXT_RED_500_COLOR_v3
+        elif tailwind_version == 3:
+            # expect "text-red-500" from tailwind utility class
+            assert p.value_of_css_property("color") in TEXT_RED_500_COLOR_v3
+        elif tailwind_version == 4:
             # expect "text-red-500" from tailwind utility class
-            assert p.value_of_css_property("color") in TEXT_RED_500_COLOR
+            assert p.value_of_css_property("color") in TEXT_RED_500_COLOR_v4
 
     # Assert external stylesheet is applying rules
     external = driver.find_elements(By.CLASS_NAME, "external")

+ 5 - 4
tests/units/compiler/test_compiler.py

@@ -155,7 +155,7 @@ def test_compile_stylesheets(tmp_path: Path, mocker: MockerFixture):
             / "styles"
             / (PageNames.STYLESHEET_ROOT + ".css")
         ),
-        "@import url('./tailwind.css'); \n"
+        "@import url('@radix-ui/themes/styles.css'); \n"
         "@import url('https://fonts.googleapis.com/css?family=Sofia&effect=neon|outline|emboss|shadow-multiple'); \n"
         "@import url('https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap.min.css'); \n"
         "@import url('https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap-theme.min.css'); \n"
@@ -215,7 +215,7 @@ def test_compile_stylesheets_scss_sass(tmp_path: Path, mocker: MockerFixture):
             / "styles"
             / (PageNames.STYLESHEET_ROOT + ".css")
         ),
-        "@import url('./tailwind.css'); \n"
+        "@import url('@radix-ui/themes/styles.css'); \n"
         "@import url('./style.css'); \n"
         f"@import url('./{Path('preprocess') / Path('styles_a.css')!s}'); \n"
         f"@import url('./{Path('preprocess') / Path('styles_b.css')!s}'); \n",
@@ -233,7 +233,7 @@ def test_compile_stylesheets_scss_sass(tmp_path: Path, mocker: MockerFixture):
             / "styles"
             / (PageNames.STYLESHEET_ROOT + ".css")
         ),
-        "@import url('./tailwind.css'); \n"
+        "@import url('@radix-ui/themes/styles.css'); \n"
         "@import url('./style.css'); \n"
         f"@import url('./{Path('preprocess') / Path('styles_a.css')!s}'); \n"
         f"@import url('./{Path('preprocess') / Path('styles_b.css')!s}'); \n",
@@ -267,6 +267,7 @@ def test_compile_stylesheets_exclude_tailwind(tmp_path, mocker: MockerFixture):
     mock = mocker.Mock()
 
     mocker.patch.object(mock, "tailwind", None)
+    mocker.patch.object(mock, "plugins", [])
     mocker.patch("reflex.compiler.compiler.get_config", return_value=mock)
 
     (assets_dir / "style.css").touch()
@@ -278,7 +279,7 @@ def test_compile_stylesheets_exclude_tailwind(tmp_path, mocker: MockerFixture):
 
     assert compiler.compile_root_stylesheet(stylesheets) == (
         str(Path(".web") / "styles" / (PageNames.STYLESHEET_ROOT + ".css")),
-        "@import url('./style.css'); \n",
+        "@import url('@radix-ui/themes/styles.css'); \n@import url('./style.css'); \n",
     )
 
 

+ 12 - 1
tests/units/test_app.py

@@ -1333,6 +1333,17 @@ def compilable_app(tmp_path) -> Generator[tuple[App, Path], None, None]:
     web_dir = app_path / ".web"
     web_dir.mkdir(parents=True)
     (web_dir / constants.PackageJson.PATH).touch()
+    (web_dir / constants.Dirs.POSTCSS_JS).touch()
+    (web_dir / constants.Dirs.POSTCSS_JS).write_text(
+        """
+module.exports = {
+  plugins: {
+    "postcss-import": {},
+    autoprefixer: {},
+  },
+};
+""",
+    )
     app = App(theme=None)
     app._get_frontend_packages = unittest.mock.Mock()
     with chdir(app_path):
@@ -1355,8 +1366,8 @@ def test_app_wrap_compile_theme(
     """
     conf = rx.Config(app_name="testing", react_strict_mode=react_strict_mode)
     mocker.patch("reflex.config._get_config", return_value=conf)
-
     app, web_dir = compilable_app
+    mocker.patch("reflex.utils.prerequisites.get_web_dir", return_value=web_dir)
     app.theme = rx.theme(accent_color="plum")
     app._compile()
     app_js_contents = (web_dir / "pages" / "_app.js").read_text()