Browse Source

double down on bun over fnm/npm (#4906)

* double down on bun over fnm/npm

* only install bun if we need a more recent version

* add a better guard to for columnheader

* upgrade bun

* remove --bun maybe?

* maybe wsl is better now

* always using system node

* remove installing node

* rename get npm registry

* check if nodejs is outdated

---------

Co-authored-by: Masen Furer <m_github@0x26.net>
Khaleel Al-Adhami 2 months ago
parent
commit
8c9bc6635e

+ 0 - 1
.github/workflows/check_node_latest.yml

@@ -10,7 +10,6 @@ on:
 
 env:
   TELEMETRY_ENABLED: false
-  REFLEX_USE_SYSTEM_NODE: true
 
 jobs:
   check_latest_node:

+ 6 - 9
.github/workflows/integration_tests_wsl.yml

@@ -23,11 +23,8 @@ env:
 
 jobs:
   example-counter-wsl:
-    timeout-minutes: 30
-    # 2019 is more stable with WSL in GH actions
-    # https://github.com/actions/runner-images/issues/5151
-    # Confirmed through trial and error. 2022 has >80% failure rate (probably BSOD)
-    runs-on: windows-2019
+    timeout-minutes: 20
+    runs-on: windows-latest
     steps:
       - uses: actions/checkout@v4
       - name: Clone Reflex Examples Repo
@@ -36,15 +33,15 @@ jobs:
           repository: reflex-dev/reflex-examples
           path: reflex-examples
 
-      - uses: Vampire/setup-wsl@v3
+      - uses: Vampire/setup-wsl@v5
         with:
           distribution: Ubuntu-24.04
 
-      - name: Install Python
+      - name: Install packages
         shell: wsl-bash {0}
         run: |
-          apt update
-          apt install -y python3 python3-pip curl dos2unix zip unzip
+          sudo apt-get update
+          sudo apt-get install -y python3 python3-pip curl dos2unix zip unzip
 
       - name: Install Uv
         shell: wsl-bash {0}

+ 1 - 1
docker-example/production-one-port/Dockerfile

@@ -21,7 +21,7 @@ WORKDIR /app
 COPY requirements.txt .
 RUN pip install -r requirements.txt
 
-# Install reflex helper utilities like bun/fnm/node
+# Install reflex helper utilities like bun/node
 COPY rxconfig.py ./
 RUN reflex init
 

+ 1 - 4
reflex/config.py

@@ -596,7 +596,7 @@ class EnvironmentVariables:
         constants.CompileContext.UNDEFINED, internal=True
     )
 
-    # Whether to use npm over bun to install frontend packages.
+    # Whether to use npm over bun to install and run the frontend.
     REFLEX_USE_NPM: EnvVar[bool] = env_var(False)
 
     # The npm registry to use.
@@ -614,9 +614,6 @@ class EnvironmentVariables:
     # Whether to use the system installed bun. If set to false, bun will be bundled with the app.
     REFLEX_USE_SYSTEM_BUN: EnvVar[bool] = env_var(False)
 
-    # Whether to use the system installed node and npm. If set to false, node and npm will be bundled with the app.
-    REFLEX_USE_SYSTEM_NODE: EnvVar[bool] = env_var(False)
-
     # The working directory for the next.js commands.
     REFLEX_WEB_WORKDIR: EnvVar[Path] = env_var(Path(constants.Dirs.WEB))
 

+ 1 - 2
reflex/constants/__init__.py

@@ -45,7 +45,7 @@ from .config import (
 )
 from .custom_components import CustomComponents
 from .event import Endpoint, EventTriggers, SocketEvent
-from .installer import Bun, Fnm, Node, PackageJson
+from .installer import Bun, Node, PackageJson
 from .route import (
     ROUTE_NOT_FOUND,
     ROUTER,
@@ -94,7 +94,6 @@ __all__ = [
     "EventTriggers",
     "Expiration",
     "Ext",
-    "Fnm",
     "GitIgnore",
     "Hooks",
     "Imports",

+ 3 - 100
reflex/constants/installer.py

@@ -1,46 +1,22 @@
-"""File for constants related to the installation process. (Bun/FNM/Node)."""
+"""File for constants related to the installation process. (Bun/Node)."""
 
 from __future__ import annotations
 
-import platform
-from pathlib import Path
 from types import SimpleNamespace
 
 from .base import IS_WINDOWS
 from .utils import classproperty
 
 
-def get_fnm_name() -> str | None:
-    """Get the appropriate fnm executable name based on the current platform.
-
-    Returns:
-            The fnm executable name for the current platform.
-    """
-    platform_os = platform.system()
-
-    if platform_os == "Windows":
-        return "fnm-windows"
-    elif platform_os == "Darwin":
-        return "fnm-macos"
-    elif platform_os == "Linux":
-        machine = platform.machine()
-        if machine == "arm" or machine.startswith("armv7"):
-            return "fnm-arm32"
-        elif machine.startswith("aarch") or machine.startswith("armv8"):
-            return "fnm-arm64"
-        return "fnm-linux"
-    return None
-
-
 # Bun config.
 class Bun(SimpleNamespace):
     """Bun constants."""
 
     # The Bun version.
-    VERSION = "1.2.0"
+    VERSION = "1.2.4"
 
     # Min Bun Version
-    MIN_VERSION = "1.1.0"
+    MIN_VERSION = "1.2.4"
 
     # URL to bun install script.
     INSTALL_URL = "https://raw.githubusercontent.com/reflex-dev/reflex/main/scripts/bun_install.sh"
@@ -81,43 +57,6 @@ registry = "{registry}"
 """
 
 
-# FNM config.
-class Fnm(SimpleNamespace):
-    """FNM constants."""
-
-    # The FNM version.
-    VERSION = "1.35.1"
-
-    FILENAME = get_fnm_name()
-
-    # The URL to the fnm release binary
-    INSTALL_URL = (
-        f"https://github.com/Schniz/fnm/releases/download/v{VERSION}/{FILENAME}.zip"
-    )
-
-    @classproperty
-    @classmethod
-    def DIR(cls) -> Path:
-        """The directory to store fnm.
-
-        Returns:
-            The directory to store fnm.
-        """
-        from reflex.config import environment
-
-        return environment.REFLEX_DIR.get() / "fnm"
-
-    @classproperty
-    @classmethod
-    def EXE(cls):
-        """The fnm executable binary.
-
-        Returns:
-            The fnm executable binary.
-        """
-        return cls.DIR / ("fnm.exe" if IS_WINDOWS else "fnm")
-
-
 # Node / NPM config
 class Node(SimpleNamespace):
     """Node/ NPM constants."""
@@ -127,42 +66,6 @@ class Node(SimpleNamespace):
     # The minimum required node version.
     MIN_VERSION = "18.18.0"
 
-    @classproperty
-    @classmethod
-    def BIN_PATH(cls):
-        """The node bin path.
-
-        Returns:
-            The node bin path.
-        """
-        return (
-            Fnm.DIR
-            / "node-versions"
-            / f"v{cls.VERSION}"
-            / "installation"
-            / ("bin" if not IS_WINDOWS else "")
-        )
-
-    @classproperty
-    @classmethod
-    def PATH(cls):
-        """The default path where node is installed.
-
-        Returns:
-            The default path where node is installed.
-        """
-        return cls.BIN_PATH / ("node.exe" if IS_WINDOWS else "node")
-
-    @classproperty
-    @classmethod
-    def NPM_PATH(cls):
-        """The default path where npm is installed.
-
-        Returns:
-            The default path where npm is installed.
-        """
-        return cls.BIN_PATH / "npm"
-
 
 class PackageJson(SimpleNamespace):
     """Constants used to build the package.json file."""

+ 7 - 1
reflex/testing.py

@@ -371,7 +371,13 @@ class AppHarness:
 
         # Start the frontend.
         self.frontend_process = reflex.utils.processes.new_process(
-            [reflex.utils.prerequisites.get_package_manager(), "run", "dev"],
+            [
+                *reflex.utils.prerequisites.get_js_package_executor(raise_on_none=True)[
+                    0
+                ],
+                "run",
+                "dev",
+            ],
             cwd=self.app_path / reflex.utils.prerequisites.get_web_dir(),
             env={"PORT": "0"},
             **FRONTEND_POPEN_ARGS,

+ 2 - 2
reflex/utils/build.py

@@ -212,7 +212,7 @@ def build(
 
     # Start the subprocess with the progress bar.
     process = processes.new_process(
-        [prerequisites.get_package_manager(), "run", command],
+        [*prerequisites.get_js_package_executor(raise_on_none=True)[0], "run", command],
         cwd=wdir,
         shell=constants.IS_WINDOWS,
     )
@@ -248,7 +248,7 @@ def setup_frontend(
     if disable_telemetry:
         processes.new_process(
             [
-                prerequisites.get_package_manager(),
+                *prerequisites.get_js_package_executor(raise_on_none=True)[0],
                 "run",
                 "next",
                 "telemetry",

+ 10 - 11
reflex/utils/exec.py

@@ -154,7 +154,11 @@ def run_frontend(root: Path, port: str, backend_present: bool = True):
     console.rule("[bold green]App Running")
     os.environ["PORT"] = str(get_config().frontend_port if port is None else port)
     run_process_and_launch_url(
-        [prerequisites.get_package_manager(), "run", "dev"],
+        [
+            *prerequisites.get_js_package_executor(raise_on_none=True)[0],
+            "run",
+            "dev",
+        ],
         backend_present,
     )
 
@@ -176,7 +180,7 @@ def run_frontend_prod(root: Path, port: str, backend_present: bool = True):
     # Run the frontend in production mode.
     console.rule("[bold green]App Running")
     run_process_and_launch_url(
-        [prerequisites.get_package_manager(), "run", "prod"],
+        [*prerequisites.get_js_package_executor(raise_on_none=True)[0], "run", "prod"],
         backend_present,
     )
 
@@ -518,13 +522,8 @@ def output_system_info():
 
     system = platform.system()
 
-    fnm_info = f"[FNM {prerequisites.get_fnm_version()} (Expected: {constants.Fnm.VERSION}) (PATH: {constants.Fnm.EXE})]"
-
-    dependencies.extend(
-        [
-            fnm_info,
-            f"[Bun {prerequisites.get_bun_version()} (Expected: {constants.Bun.VERSION}) (PATH: {path_ops.get_bun_path()})]",
-        ],
+    dependencies.append(
+        f"[Bun {prerequisites.get_bun_version()} (Expected: {constants.Bun.VERSION}) (PATH: {path_ops.get_bun_path()})]"
     )
 
     if system == "Linux":
@@ -540,10 +539,10 @@ def output_system_info():
         console.debug(f"{dep}")
 
     console.debug(
-        f"Using package installer at: {prerequisites.get_install_package_manager(on_failure_return_none=True)}"
+        f"Using package installer at: {prerequisites.get_nodejs_compatible_package_managers(raise_on_none=False)}"
     )
     console.debug(
-        f"Using package executer at: {prerequisites.get_package_manager(on_failure_return_none=True)}"
+        f"Using package executer at: {prerequisites.get_js_package_executor(raise_on_none=False)}"
     )
     if system != "Windows":
         console.debug(f"Unzip path: {path_ops.which('unzip')}")

+ 3 - 23
reflex/utils/path_ops.py

@@ -9,7 +9,6 @@ import shutil
 import stat
 from pathlib import Path
 
-from reflex import constants
 from reflex.config import environment, get_config
 
 # Shorthand for join.
@@ -146,15 +145,6 @@ def which(program: str | Path) -> Path | None:
     return Path(which_result) if which_result else None
 
 
-def use_system_node() -> bool:
-    """Check if the system node should be used.
-
-    Returns:
-        Whether the system node should be used.
-    """
-    return environment.REFLEX_USE_SYSTEM_NODE.get()
-
-
 def use_system_bun() -> bool:
     """Check if the system bun should be used.
 
@@ -170,11 +160,7 @@ def get_node_bin_path() -> Path | None:
     Returns:
         The path to the node bin folder.
     """
-    bin_path = Path(constants.Node.BIN_PATH)
-    if not bin_path.exists():
-        path = which("node")
-        return path.parent.absolute() if path else None
-    return bin_path.absolute()
+    return bin_path.parent.absolute() if (bin_path := get_node_path()) else None
 
 
 def get_node_path() -> Path | None:
@@ -183,10 +169,7 @@ def get_node_path() -> Path | None:
     Returns:
         The path to the node binary file.
     """
-    node_path = Path(constants.Node.PATH)
-    if use_system_node() or not node_path.exists():
-        node_path = which("node")
-    return node_path
+    return which("node")
 
 
 def get_npm_path() -> Path | None:
@@ -195,10 +178,7 @@ def get_npm_path() -> Path | None:
     Returns:
         The path to the npm binary file.
     """
-    npm_path = Path(constants.Node.NPM_PATH)
-    if use_system_node() or not npm_path.exists():
-        npm_path = which("npm")
-    return npm_path.absolute() if npm_path else None
+    return npm_path.absolute() if (npm_path := which("npm")) else None
 
 
 def get_bun_path() -> Path | None:

+ 113 - 181
reflex/utils/prerequisites.py

@@ -14,7 +14,6 @@ import platform
 import random
 import re
 import shutil
-import stat
 import sys
 import tempfile
 import time
@@ -23,7 +22,7 @@ import zipfile
 from datetime import datetime
 from pathlib import Path
 from types import ModuleType
-from typing import Callable, NamedTuple
+from typing import Callable, NamedTuple, Sequence
 from urllib.parse import urlparse
 
 import httpx
@@ -43,13 +42,11 @@ from reflex.utils.exceptions import (
     SystemPackageMissingError,
 )
 from reflex.utils.format import format_library_name
-from reflex.utils.registry import _get_npm_registry
+from reflex.utils.registry import get_npm_registry
 
 if typing.TYPE_CHECKING:
     from reflex.app import App
 
-CURRENTLY_INSTALLING_NODE = False
-
 
 class AppInfo(NamedTuple):
     """A tuple containing the app instance and module."""
@@ -191,24 +188,6 @@ def get_node_version() -> version.Version | None:
         return None
 
 
-def get_fnm_version() -> version.Version | None:
-    """Get the version of fnm.
-
-    Returns:
-        The version of FNM.
-    """
-    try:
-        result = processes.new_process([constants.Fnm.EXE, "--version"], run=True)
-        return version.parse(result.stdout.split(" ")[1])  # pyright: ignore [reportOptionalMemberAccess, reportAttributeAccessIssue]
-    except (FileNotFoundError, TypeError):
-        return None
-    except version.InvalidVersion as e:
-        console.warn(
-            f"The detected fnm version ({e.args[0]}) is not valid. Defaulting to None."
-        )
-        return None
-
-
 def get_bun_version() -> version.Version | None:
     """Get the version of bun.
 
@@ -231,42 +210,107 @@ def get_bun_version() -> version.Version | None:
         return None
 
 
-def get_install_package_manager(on_failure_return_none: bool = False) -> str | None:
-    """Get the package manager executable for installation.
-      Currently, bun is used for installation only.
+def prefer_npm_over_bun() -> bool:
+    """Check if npm should be preferred over bun.
+
+    Returns:
+        If npm should be preferred over bun.
+    """
+    return npm_escape_hatch() or (
+        constants.IS_WINDOWS and windows_check_onedrive_in_path()
+    )
+
+
+def get_nodejs_compatible_package_managers(
+    raise_on_none: bool = True,
+) -> Sequence[str]:
+    """Get the package manager executable for installation. Typically, bun is used for installation.
 
     Args:
-        on_failure_return_none: Whether to return None on failure.
+        raise_on_none: Whether to raise an error if the package manager is not found.
 
     Returns:
         The path to the package manager.
+
+    Raises:
+        FileNotFoundError: If the package manager is not found and raise_on_none is True.
     """
-    if constants.IS_WINDOWS and (
-        windows_check_onedrive_in_path() or windows_npm_escape_hatch()
+    bun_package_manager = (
+        str(bun_path) if (bun_path := path_ops.get_bun_path()) else None
+    )
+
+    npm_package_manager = (
+        str(npm_path) if (npm_path := path_ops.get_npm_path()) else None
+    )
+
+    if prefer_npm_over_bun():
+        package_managers = [npm_package_manager, bun_package_manager]
+    else:
+        package_managers = [bun_package_manager, npm_package_manager]
+
+    package_managers = list(filter(None, package_managers))
+
+    if not package_managers and not raise_on_none:
+        raise FileNotFoundError(
+            "Bun or npm not found. You might need to rerun `reflex init` or install either."
+        )
+
+    return package_managers
+
+
+def is_outdated_nodejs_installed():
+    """Check if the installed Node.js version is outdated.
+
+    Returns:
+        If the installed Node.js version is outdated.
+    """
+    current_version = get_node_version()
+    if current_version is not None and current_version < version.parse(
+        constants.Node.MIN_VERSION
     ):
-        return get_package_manager(on_failure_return_none)
-    return str(get_config().bun_path)
+        console.warn(
+            f"Your version ({current_version}) of Node.js is out of date. Upgrade to {constants.Node.MIN_VERSION} or higher."
+        )
+        return True
+    return False
 
 
-def get_package_manager(on_failure_return_none: bool = False) -> str | None:
-    """Get the package manager executable for running app.
-      Currently on unix systems, npm is used for running the app only.
+def get_js_package_executor(raise_on_none: bool = False) -> Sequence[Sequence[str]]:
+    """Get the paths to package managers for running commands. Ordered by preference.
+    This is currently identical to get_install_package_managers, but may change in the future.
 
     Args:
-        on_failure_return_none: Whether to return None on failure.
+        raise_on_none: Whether to raise an error if no package managers is not found.
 
     Returns:
-        The path to the package manager.
+        The paths to the package managers as a list of lists, where each list is the command to run and its arguments.
 
     Raises:
-        FileNotFoundError: If the package manager is not found.
+        FileNotFoundError: If no package managers are found and raise_on_none is True.
     """
-    npm_path = path_ops.get_npm_path()
-    if npm_path is not None:
-        return str(npm_path)
-    if on_failure_return_none:
-        return None
-    raise FileNotFoundError("NPM not found. You may need to run `reflex init`.")
+    bun_package_manager = (
+        [str(bun_path)] + (["--bun"] if is_outdated_nodejs_installed() else [])
+        if (bun_path := path_ops.get_bun_path())
+        else None
+    )
+
+    npm_package_manager = (
+        [str(npm_path)] if (npm_path := path_ops.get_npm_path()) else None
+    )
+
+    if prefer_npm_over_bun():
+        package_managers = [npm_package_manager, bun_package_manager]
+    else:
+        package_managers = [bun_package_manager, npm_package_manager]
+
+    package_managers = list(filter(None, package_managers))
+
+    if not package_managers and raise_on_none:
+        raise FileNotFoundError(
+            "Bun or npm not found. You might need to rerun `reflex init` or install either."
+        )
+
+    return package_managers
 
 
 def windows_check_onedrive_in_path() -> bool:
@@ -278,8 +322,8 @@ def windows_check_onedrive_in_path() -> bool:
     return "onedrive" in str(Path.cwd()).lower()
 
 
-def windows_npm_escape_hatch() -> bool:
-    """For windows, if the user sets REFLEX_USE_NPM, use npm instead of bun.
+def npm_escape_hatch() -> bool:
+    """If the user sets REFLEX_USE_NPM, prefer npm over bun.
 
     Returns:
         If the user has set REFLEX_USE_NPM.
@@ -862,7 +906,7 @@ def initialize_bun_config():
         bunfig_content = custom_bunfig.read_text()
         console.info(f"Copying custom bunfig.toml inside {get_web_dir()} folder")
     else:
-        best_registry = _get_npm_registry()
+        best_registry = get_npm_registry()
         bunfig_content = constants.Bun.DEFAULT_CONFIG.format(registry=best_registry)
 
     bun_config_path.write_text(bunfig_content)
@@ -970,92 +1014,6 @@ def download_and_run(url: str, *args, show_status: bool = False, **env):
     show(f"Installing {url}", process)
 
 
-def download_and_extract_fnm_zip():
-    """Download and run a script.
-
-    Raises:
-        Exit: If an error occurs while downloading or extracting the FNM zip.
-    """
-    # Download the zip file
-    url = constants.Fnm.INSTALL_URL
-    console.debug(f"Downloading {url}")
-    fnm_zip_file: Path = constants.Fnm.DIR / f"{constants.Fnm.FILENAME}.zip"
-    # Function to download and extract the FNM zip release.
-    try:
-        # Download the FNM zip release.
-        # TODO: show progress to improve UX
-        response = net.get(url, follow_redirects=True)
-        response.raise_for_status()
-        with fnm_zip_file.open("wb") as output_file:
-            for chunk in response.iter_bytes():
-                output_file.write(chunk)
-
-        # Extract the downloaded zip file.
-        with zipfile.ZipFile(fnm_zip_file, "r") as zip_ref:
-            zip_ref.extractall(constants.Fnm.DIR)
-
-        console.debug("FNM package downloaded and extracted successfully.")
-    except Exception as e:
-        console.error(f"An error occurred while downloading fnm package: {e}")
-        raise typer.Exit(1) from e
-    finally:
-        # Clean up the downloaded zip file.
-        path_ops.rm(fnm_zip_file)
-
-
-def install_node():
-    """Install fnm and nodejs for use by Reflex.
-    Independent of any existing system installations.
-    """
-    if not constants.Fnm.FILENAME:
-        # fnm only support Linux, macOS and Windows distros.
-        console.debug("")
-        return
-
-    # Skip installation if check_node_version() checks out
-    if check_node_version():
-        console.debug("Skipping node installation as it is already installed.")
-        return
-
-    path_ops.mkdir(constants.Fnm.DIR)
-    if not constants.Fnm.EXE.exists():
-        download_and_extract_fnm_zip()
-
-    if constants.IS_WINDOWS:
-        # Install node
-        fnm_exe = Path(constants.Fnm.EXE).resolve()
-        fnm_dir = Path(constants.Fnm.DIR).resolve()
-        process = processes.new_process(
-            [
-                "powershell",
-                "-Command",
-                f'& "{fnm_exe}" install {constants.Node.VERSION} --fnm-dir "{fnm_dir}"',
-            ],
-        )
-    else:  # All other platforms (Linux, MacOS).
-        # Add execute permissions to fnm executable.
-        constants.Fnm.EXE.chmod(stat.S_IXUSR)
-        # Install node.
-        # Specify arm64 arch explicitly for M1s and M2s.
-        architecture_arg = (
-            ["--arch=arm64"]
-            if platform.system() == "Darwin" and platform.machine() == "arm64"
-            else []
-        )
-
-        process = processes.new_process(
-            [
-                constants.Fnm.EXE,
-                "install",
-                *architecture_arg,
-                constants.Node.VERSION,
-                "--fnm-dir",
-                constants.Fnm.DIR,
-            ],
-        )
-    processes.show_status("Installing node", process)
-
-
 def install_bun():
     """Install bun onto the user's system.
 
@@ -1069,7 +1027,9 @@ def install_bun():
         )
 
     # Skip if bun is already installed.
-    if get_bun_version() == version.parse(constants.Bun.VERSION):
+    if (current_version := get_bun_version()) and current_version >= version.parse(
+        constants.Bun.MIN_VERSION
+    ):
         console.debug("Skipping bun installation as it is already installed.")
         return
 
@@ -1157,38 +1117,19 @@ def install_frontend_packages(packages: set[str], config: Config):
         packages: A list of package names to be installed.
         config: The config object.
 
-    Raises:
-        FileNotFoundError: If the package manager is not found.
-
     Example:
         >>> install_frontend_packages(["react", "react-dom"], get_config())
     """
-    # unsupported archs(arm and 32bit machines) will use npm anyway. so we dont have to run npm twice
-    fallback_command = (
-        get_package_manager(on_failure_return_none=True)
-        if (
-            not constants.IS_WINDOWS
-            or (constants.IS_WINDOWS and not windows_check_onedrive_in_path())
-        )
-        else None
+    install_package_managers = get_nodejs_compatible_package_managers(
+        raise_on_none=True
     )
 
-    install_package_manager = (
-        get_install_package_manager(on_failure_return_none=True) or fallback_command
-    )
-
-    if install_package_manager is None:
-        raise FileNotFoundError(
-            "Could not find a package manager to install frontend packages. You may need to run `reflex init`."
-        )
-
-    fallback_command = (
-        fallback_command if fallback_command is not install_package_manager else None
-    )
+    primary_package_manager = install_package_managers[0]
+    fallbacks = install_package_managers[1:]
 
-    processes.run_process_with_fallback(
-        [install_package_manager, "install", "--legacy-peer-deps"],
-        fallback=fallback_command,
+    processes.run_process_with_fallbacks(
+        [primary_package_manager, "install", "--legacy-peer-deps"],
+        fallbacks=fallbacks,
         analytics_enabled=True,
         show_status_message="Installing base frontend packages",
         cwd=get_web_dir(),
@@ -1196,16 +1137,16 @@ def install_frontend_packages(packages: set[str], config: Config):
     )
 
     if config.tailwind is not None:
-        processes.run_process_with_fallback(
+        processes.run_process_with_fallbacks(
             [
-                install_package_manager,
+                primary_package_manager,
                 "add",
                 "--legacy-peer-deps",
                 "-d",
                 constants.Tailwind.VERSION,
                 *((config.tailwind or {}).get("plugins", [])),
             ],
-            fallback=fallback_command,
+            fallbacks=fallbacks,
             analytics_enabled=True,
             show_status_message="Installing tailwind",
             cwd=get_web_dir(),
@@ -1214,9 +1155,9 @@ def install_frontend_packages(packages: set[str], config: Config):
 
     # Install custom packages defined in frontend_packages
     if len(packages) > 0:
-        processes.run_process_with_fallback(
-            [install_package_manager, "add", "--legacy-peer-deps", *packages],
-            fallback=fallback_command,
+        processes.run_process_with_fallbacks(
+            [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(),
@@ -1338,24 +1279,19 @@ def validate_frontend_dependencies(init: bool = True):
         Exit: If the package manager is invalid.
     """
     if not init:
-        # we only need to validate the package manager when running app.
-        # `reflex init` will install the deps anyway(if applied).
-        package_manager = get_package_manager()
-        if not package_manager:
-            console.error(
-                "Could not find NPM package manager. Make sure you have node installed."
-            )
-            raise typer.Exit(1)
+        try:
+            get_js_package_executor(raise_on_none=True)
+        except FileNotFoundError as e:
+            raise typer.Exit(1) from e
 
+    if prefer_npm_over_bun():
         if not check_node_version():
             node_version = get_node_version()
             console.error(
                 f"Reflex requires node version {constants.Node.MIN_VERSION} or higher to run, but the detected version is {node_version}",
             )
             raise typer.Exit(1)
-
-    if init:
-        # we only need bun for package install on `reflex init`.
+    else:
         validate_bun()
 
 
@@ -1400,12 +1336,8 @@ def initialize_frontend_dependencies():
     """Initialize all the frontend dependencies."""
     # validate dependencies before install
     validate_frontend_dependencies()
-    # Avoid warning about Node installation while we're trying to install it.
-    global CURRENTLY_INSTALLING_NODE
-    CURRENTLY_INSTALLING_NODE = True
     # Install the frontend dependencies.
-    processes.run_concurrently(install_node, install_bun)
-    CURRENTLY_INSTALLING_NODE = False
+    processes.run_concurrently(install_bun)
     # Set up the web directory.
     initialize_web_directory()
 

+ 21 - 18
reflex/utils/processes.py

@@ -10,7 +10,7 @@ import signal
 import subprocess
 from concurrent import futures
 from pathlib import Path
-from typing import Callable, Generator, Tuple
+from typing import Callable, Generator, Sequence, Tuple
 
 import psutil
 import typer
@@ -171,14 +171,9 @@ def new_process(
 
     # Add node_bin_path to the PATH environment variable.
     if not environment.REFLEX_BACKEND_ONLY.get():
-        node_bin_path = str(path_ops.get_node_bin_path())
-        if not node_bin_path and not prerequisites.CURRENTLY_INSTALLING_NODE:
-            console.warn(
-                "The path to the Node binary could not be found. Please ensure that Node is properly "
-                "installed and added to your system's PATH environment variable or try running "
-                "`reflex init` again."
-            )
-        path_env = os.pathsep.join([node_bin_path, path_env])
+        node_bin_path = path_ops.get_node_bin_path()
+        if node_bin_path:
+            path_env = os.pathsep.join([str(node_bin_path), path_env])
 
     env: dict[str, str] = {
         **os.environ,
@@ -380,11 +375,11 @@ def get_command_with_loglevel(command: list[str]) -> list[str]:
     return command
 
 
-def run_process_with_fallback(
+def run_process_with_fallbacks(
     args: list[str],
     *,
     show_status_message: str,
-    fallback: str | list | None = None,
+    fallbacks: str | Sequence[str] | Sequence[Sequence[str]] | None = None,
     analytics_enabled: bool = False,
     **kwargs,
 ):
@@ -393,12 +388,12 @@ def run_process_with_fallback(
     Args:
         args: A string, or a sequence of program arguments.
         show_status_message: The status message to be displayed in the console.
-        fallback: The fallback command to run.
+        fallbacks: The fallback command to run if the initial command fails.
         analytics_enabled: Whether analytics are enabled for this command.
         kwargs: Kwargs to pass to new_process function.
     """
     process = new_process(get_command_with_loglevel(args), **kwargs)
-    if fallback is None:
+    if not fallbacks:
         # No fallback given, or this _is_ the fallback command.
         show_status(
             show_status_message,
@@ -408,16 +403,24 @@ def run_process_with_fallback(
     else:
         # Suppress errors for initial command, because we will try to fallback
         show_status(show_status_message, process, suppress_errors=True)
+
+        current_fallback = fallbacks[0] if not isinstance(fallbacks, str) else fallbacks
+        next_fallbacks = fallbacks[1:] if not isinstance(fallbacks, str) else None
+
         if process.returncode != 0:
             # retry with fallback command.
-            fallback_args = [fallback, *args[1:]]
+            fallback_with_args = (
+                [current_fallback, *args[1:]]
+                if isinstance(fallbacks, str)
+                else [*current_fallback, *args[1:]]
+            )
             console.warn(
-                f"There was an error running command: {args}. Falling back to: {fallback_args}."
+                f"There was an error running command: {args}. Falling back to: {fallback_with_args}."
             )
-            run_process_with_fallback(
-                fallback_args,
+            run_process_with_fallbacks(
+                fallback_with_args,
                 show_status_message=show_status_message,
-                fallback=None,
+                fallbacks=next_fallbacks,
                 analytics_enabled=analytics_enabled,
                 **kwargs,
             )

+ 5 - 5
reflex/utils/registry.py

@@ -35,11 +35,11 @@ def average_latency(registry: str, attempts: int = 3) -> int:
     return sum(latency(registry) for _ in range(attempts)) // attempts
 
 
-def get_best_registry() -> str:
+def _get_best_registry() -> str:
     """Get the best registry based on latency.
 
     Returns:
-        str: The best registry.
+        The best registry.
     """
     registries = [
         "https://registry.npmjs.org",
@@ -49,10 +49,10 @@ def get_best_registry() -> str:
     return min(registries, key=average_latency)
 
 
-def _get_npm_registry() -> str:
+def get_npm_registry() -> str:
     """Get npm registry. If environment variable is set, use it first.
 
     Returns:
-        str:
+        The npm registry.
     """
-    return environment.NPM_CONFIG_REGISTRY.get() or get_best_registry()
+    return environment.NPM_CONFIG_REGISTRY.get() or _get_best_registry()

+ 1 - 0
tests/integration/tests_playwright/test_table.py

@@ -87,6 +87,7 @@ def test_table(page: Page, table_app: AppHarness):
     table = page.get_by_role("table")
 
     # Check column headers
+    expect(table.get_by_role("columnheader")).to_have_count(3)
     headers = table.get_by_role("columnheader")
     for header, exp_value in zip(headers.all(), expected_col_headers, strict=True):
         expect(header).to_have_text(exp_value)

+ 0 - 91
tests/units/utils/test_utils.py

@@ -19,7 +19,6 @@ import typer
 from packaging import version
 
 from reflex import constants
-from reflex.base import Base
 from reflex.config import environment
 from reflex.event import EventHandler
 from reflex.state import BaseState
@@ -499,96 +498,6 @@ def test_validate_app_name(tmp_path, mocker):
         prerequisites.validate_app_name(app_name="1_test")
 
 
-def test_node_install_windows(tmp_path, mocker):
-    """Require user to install node manually for windows if node is not installed.
-
-    Args:
-        tmp_path: Test working dir.
-        mocker: Pytest mocker object.
-    """
-    fnm_root_path = tmp_path / "reflex" / "fnm"
-    fnm_exe = fnm_root_path / "fnm.exe"
-
-    mocker.patch("reflex.utils.prerequisites.constants.Fnm.DIR", fnm_root_path)
-    mocker.patch("reflex.utils.prerequisites.constants.Fnm.EXE", fnm_exe)
-    mocker.patch("reflex.utils.prerequisites.constants.IS_WINDOWS", True)
-    mocker.patch("reflex.utils.processes.new_process")
-    mocker.patch("reflex.utils.processes.stream_logs")
-
-    class Resp(Base):
-        status_code = 200
-        text = "test"
-
-    mocker.patch("httpx.stream", return_value=Resp())
-    download = mocker.patch("reflex.utils.prerequisites.download_and_extract_fnm_zip")
-    mocker.patch("reflex.utils.prerequisites.zipfile.ZipFile")
-    mocker.patch("reflex.utils.prerequisites.path_ops.rm")
-
-    prerequisites.install_node()
-
-    assert fnm_root_path.exists()
-    download.assert_called_once()
-
-
-@pytest.mark.parametrize(
-    "machine, system",
-    [
-        ("x64", "Darwin"),
-        ("arm64", "Darwin"),
-        ("x64", "Windows"),
-        ("arm64", "Windows"),
-        ("armv7", "Linux"),
-        ("armv8-a", "Linux"),
-        ("armv8.1-a", "Linux"),
-        ("armv8.2-a", "Linux"),
-        ("armv8.3-a", "Linux"),
-        ("armv8.4-a", "Linux"),
-        ("aarch64", "Linux"),
-        ("aarch32", "Linux"),
-    ],
-)
-def test_node_install_unix(tmp_path, mocker, machine, system):
-    fnm_root_path = tmp_path / "reflex" / "fnm"
-    fnm_exe = fnm_root_path / "fnm"
-
-    mocker.patch("reflex.utils.prerequisites.constants.Fnm.DIR", fnm_root_path)
-    mocker.patch("reflex.utils.prerequisites.constants.Fnm.EXE", fnm_exe)
-    mocker.patch("reflex.utils.prerequisites.constants.IS_WINDOWS", False)
-    mocker.patch("reflex.utils.prerequisites.platform.machine", return_value=machine)
-    mocker.patch("reflex.utils.prerequisites.platform.system", return_value=system)
-
-    class Resp(Base):
-        status_code = 200
-        text = "test"
-
-    mocker.patch("httpx.stream", return_value=Resp())
-    download = mocker.patch("reflex.utils.prerequisites.download_and_extract_fnm_zip")
-    process = mocker.patch("reflex.utils.processes.new_process")
-    chmod = mocker.patch("pathlib.Path.chmod")
-    mocker.patch("reflex.utils.processes.stream_logs")
-
-    prerequisites.install_node()
-
-    assert fnm_root_path.exists()
-    download.assert_called_once()
-    if system == "Darwin" and machine == "arm64":
-        process.assert_called_with(
-            [
-                fnm_exe,
-                "install",
-                "--arch=arm64",
-                constants.Node.VERSION,
-                "--fnm-dir",
-                fnm_root_path,
-            ]
-        )
-    else:
-        process.assert_called_with(
-            [fnm_exe, "install", constants.Node.VERSION, "--fnm-dir", fnm_root_path]
-        )
-    chmod.assert_called_once()
-
-
 def test_bun_install_without_unzip(mocker):
     """Test that an error is thrown when installing bun with unzip not installed.