diff --git a/news/6663.feature.md b/news/6663.feature.md new file mode 100644 index 00000000000..896df0a3325 --- /dev/null +++ b/news/6663.feature.md @@ -0,0 +1 @@ +Add a `preview` run mode (`reflex run --env preview`) that hot reloads like `dev` but serves a freshly built, un-minified frontend bundle mounted into the backend instead of running the Vite dev server. Minification, CSS minification, autoprefixer, and sourcemaps are disabled by default for faster rebuilds and readable output (each overridable via `VITE_MINIFY`, `REFLEX_NO_AUTOPREFIXER`, and `VITE_SOURCEMAP`). diff --git a/packages/reflex-base/news/6663.feature.md b/packages/reflex-base/news/6663.feature.md new file mode 100644 index 00000000000..b64f11e0e66 --- /dev/null +++ b/packages/reflex-base/news/6663.feature.md @@ -0,0 +1 @@ +Add `Env.PREVIEW`, the `VITE_MINIFY` and `REFLEX_NO_AUTOPREFIXER` environment variables, a `minify`/`cssMinify` option on the vite config template, and a `REFLEX_NO_AUTOPREFIXER` toggle in the generated `postcss.config.js` to support the new `preview` run mode. diff --git a/packages/reflex-base/src/reflex_base/.templates/web/postcss.config.js b/packages/reflex-base/src/reflex_base/.templates/web/postcss.config.js index 3fe1f0379d3..407b6f8afaf 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/postcss.config.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/postcss.config.js @@ -1,6 +1,10 @@ +const noVendorPrefix = /^(1|true)$/i.test( + process.env.REFLEX_NO_AUTOPREFIXER ?? "", +); + export default { plugins: { "postcss-import": {}, - autoprefixer: {}, + autoprefixer: noVendorPrefix ? false : {}, }, }; diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index 0d2f750e9eb..c58749fa2f2 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -543,6 +543,7 @@ def vite_config_template( force_full_reload: bool, experimental_hmr: bool, sourcemap: bool | Literal["inline", "hidden"], + minify: bool = True, allowed_hosts: bool | list[str] = False, ): """Template for vite.config.js. @@ -553,6 +554,7 @@ def vite_config_template( force_full_reload: Whether to force a full reload on changes. experimental_hmr: Whether to enable experimental HMR features. sourcemap: The sourcemap configuration. + minify: Whether to minify the build output. allowed_hosts: Allow all hosts (True), specific hosts (list of strings), or only localhost (False). Returns: @@ -611,6 +613,8 @@ def vite_config_template( safariCacheBustPlugin(), ].concat({"[fullReload()]" if force_full_reload else "[]"}), build: {{ + minify: {"true" if minify else "false"}, + cssMinify: {"true" if minify else "false"}, sourcemap: {"true" if sourcemap is True else "false" if sourcemap is False else repr(sourcemap)}, rollupOptions: {{ onwarn(warning, warn) {{ diff --git a/packages/reflex-base/src/reflex_base/constants/base.py b/packages/reflex-base/src/reflex_base/constants/base.py index 620d9bd7ee0..7a3f8392b80 100644 --- a/packages/reflex-base/src/reflex_base/constants/base.py +++ b/packages/reflex-base/src/reflex_base/constants/base.py @@ -189,6 +189,7 @@ class Env(str, Enum): """The environment modes.""" DEV = "dev" + PREVIEW = "preview" PROD = "prod" diff --git a/packages/reflex-base/src/reflex_base/environment.py b/packages/reflex-base/src/reflex_base/environment.py index 99521d1a339..7ca79e7c097 100644 --- a/packages/reflex-base/src/reflex_base/environment.py +++ b/packages/reflex-base/src/reflex_base/environment.py @@ -689,6 +689,12 @@ class EnvironmentVariables: # Whether to generate sourcemaps for the frontend. VITE_SOURCEMAP: EnvVar[Literal[False, True, "inline", "hidden"]] = env_var(False) # noqa: RUF038 + # Whether to minify the frontend build output. Disabled by preview mode for readable bundles. + VITE_MINIFY: EnvVar[bool] = env_var(True) + + # Read by the generated postcss.config.js to skip autoprefixer in preview mode. + REFLEX_NO_AUTOPREFIXER: EnvVar[bool] = env_var(False) + # Whether to enable SSR for the frontend. REFLEX_SSR: EnvVar[bool] = env_var(True) diff --git a/reflex/app.py b/reflex/app.py index 9c0d2af8db0..87ae16126df 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -710,11 +710,31 @@ def __call__(self) -> ASGIApp: # rx.asset(shared=True) symlink re-creation doesn't trigger further reloads. remove_stale_external_asset_symlinks() + trigger = get_backend_compile_trigger() self._compile( prerender_routes=should_prerender_routes(), - trigger=get_backend_compile_trigger(), + trigger=trigger, ) + # In preview mode the frontend is served as a mounted static bundle rather + # than by the Vite dev server, so each hot reload must re-run the frontend + # build against the freshly compiled output. + if ( + trigger == "hot_reload" + and environment.REFLEX_ENV_MODE.get() == constants.Env.PREVIEW + and environment.REFLEX_MOUNT_FRONTEND_COMPILED_APP.get() + ): + from reflex.utils import build + + # A build failure (e.g. a bad import in user code) must not take the + # backend down: keep serving the previous build until the next save. + try: + build.build() + except (Exception, SystemExit) as exc: + console.error( + f"Frontend build failed; serving the previous build. {exc}" + ) + config = get_config() for plugin in config.plugins: diff --git a/reflex/reflex.py b/reflex/reflex.py index 3c340fccea2..2bfc54d9ab5 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -224,6 +224,52 @@ def _run_dev( exec.kill(exec.frontend_process.pid) +def _run_preview(running_mode: constants.RunningMode, port: int, host: str): + """Run the app in preview mode. + + Like dev mode, but instead of running the Vite dev server it serves a freshly + built (un-minified) frontend bundle mounted into the backend on a single port. + The backend still hot reloads, and each reload re-runs the frontend build + against the newly compiled output, so a manual browser refresh shows changes. + """ + import atexit + + from reflex.utils import build, exec, processes, telemetry + + config = get_config() + + config._set_persistent(frontend_port=port, backend_port=port) + + # Mount the compiled frontend into the dev backend so no Vite server is needed. + environment.REFLEX_MOUNT_FRONTEND_COMPILED_APP.set( + running_mode.has_frontend() and running_mode.has_backend() + ) + + if running_mode.has_frontend(): + # Compile the app and produce the initial frontend build. + _compile_app() + build.setup_frontend_prod(Path.cwd()) + + # Post a telemetry event. + telemetry.send("run-preview") + + # Display custom message when there is a keyboard interrupt. + atexit.register(processes.atexit_handler) + + exec.notify_app_running() + exec.notify_frontend( + f"http://{host}:{port}", + backend_present=running_mode.has_backend(), + ) + + if running_mode.has_backend(): + exec.run_backend( + host, port, config.loglevel.subprocess_level(), running_mode.has_frontend() + ) + else: + exec.run_frontend_prod(host, port) + + def _run_prod(running_mode: constants.RunningMode, port: int, host: str): import atexit @@ -278,12 +324,14 @@ def _run( console.error("Cannot specify --backend-port when not running backend.") raise SystemExit(1) if ( - env == constants.Env.PROD + env in (constants.Env.PROD, constants.Env.PREVIEW) and frontend_port and backend_port and frontend_port != backend_port ): - console.error("In production, frontend and backend must run on the same port.") + console.error( + f"In {env.value} mode, frontend and backend must run on the same port." + ) raise SystemExit(1) config = get_config() @@ -293,6 +341,17 @@ def _run( # Set env mode in the environment environment.REFLEX_ENV_MODE.set(env) + # Preview serves a real (but readable) frontend bundle: disable JS/CSS + # minification by default for readable output and to speed up rebuilds. + # Sourcemaps are left off (the default) since un-minified output is already + # debuggable, and autoprefixer is skipped (vendor prefixes are unnecessary for + # local dev). All remain overridable via the corresponding env vars. + if env == constants.Env.PREVIEW: + if not environment.VITE_MINIFY.is_set(): + environment.VITE_MINIFY.set(False) + if not environment.REFLEX_NO_AUTOPREFIXER.is_set(): + environment.REFLEX_NO_AUTOPREFIXER.set(True) + # Show system info exec.output_system_info() @@ -373,7 +432,10 @@ def _run( auto_increment=requested_port is None, ) - _run_prod(running_mode, port, backend_host) + if env == constants.Env.PREVIEW: + _run_preview(running_mode, port, backend_host) + else: + _run_prod(running_mode, port, backend_host) @cli.command() @@ -382,7 +444,10 @@ def _run( "--env", type=click.Choice([e.value for e in constants.Env], case_sensitive=False), default=constants.Env.DEV.value, - help="The environment to run the app in.", + help=( + "The environment to run the app in. 'preview' hot reloads like 'dev' but " + "serves a freshly built, un-minified frontend bundle instead of the Vite dev server." + ), ) @click.option( "--frontend-only", @@ -464,7 +529,7 @@ def run( running_mode = prerequisites.check_running_mode(frontend_only, backend_only) _run( - env=constants.Env.DEV if env == constants.Env.DEV else constants.Env.PROD, + env=constants.Env(env), running_mode=running_mode, frontend_port=frontend_port, backend_port=backend_port, @@ -538,7 +603,9 @@ def compile(dry: bool, rich: bool): ) @click.option( "--env", - type=click.Choice([e.value for e in constants.Env], case_sensitive=False), + type=click.Choice( + [constants.Env.DEV.value, constants.Env.PROD.value], case_sensitive=False + ), default=constants.Env.PROD.value, help="The environment to export the app in.", ) diff --git a/reflex/utils/frontend_skeleton.py b/reflex/utils/frontend_skeleton.py index 966e14e2686..0bc1e377d5a 100644 --- a/reflex/utils/frontend_skeleton.py +++ b/reflex/utils/frontend_skeleton.py @@ -561,6 +561,7 @@ def _compile_vite_config(config: Config): force_full_reload=environment.VITE_FORCE_FULL_RELOAD.get(), experimental_hmr=environment.VITE_EXPERIMENTAL_HMR.get(), sourcemap=environment.VITE_SOURCEMAP.get(), + minify=environment.VITE_MINIFY.get(), allowed_hosts=config.vite_allowed_hosts, ) diff --git a/tests/units/utils/test_utils.py b/tests/units/utils/test_utils.py index 4c60761e62e..7376b60b618 100644 --- a/tests/units/utils/test_utils.py +++ b/tests/units/utils/test_utils.py @@ -815,3 +815,54 @@ def test_is_prod_mode() -> None: assert utils_exec.is_prod_mode() environment.REFLEX_ENV_MODE.set(None) assert not utils_exec.is_prod_mode() + + +def test_preview_env_is_not_prod_mode() -> None: + """Preview is a development mode, so is_prod_mode must stay False.""" + environment.REFLEX_ENV_MODE.set(constants.Env.PREVIEW) + try: + assert not utils_exec.is_prod_mode() + finally: + environment.REFLEX_ENV_MODE.set(None) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("dev", constants.Env.DEV), + ("preview", constants.Env.PREVIEW), + ("prod", constants.Env.PROD), + ], +) +def test_env_enum_roundtrip(value: str, expected: constants.Env) -> None: + """Each env string maps to the matching Env member (used by the run CLI).""" + assert constants.Env(value) is expected + + +@pytest.mark.parametrize("minify", [True, False]) +def test_vite_config_template_minify(minify: bool) -> None: + """The vite config template emits the requested build.minify value.""" + from reflex.compiler import templates as compiler_templates + + config = compiler_templates.vite_config_template( + base="/", + hmr=True, + force_full_reload=False, + experimental_hmr=False, + sourcemap=False, + minify=minify, + ) + expected = "true" if minify else "false" + assert f"minify: {expected}," in config + # CSS minification follows the JS minify flag. + assert f"cssMinify: {expected}," in config + + +@pytest.mark.parametrize("minify", [True, False]) +def test_compile_vite_config_reads_minify_env( + minify: bool, monkeypatch: pytest.MonkeyPatch +) -> None: + """_compile_vite_config threads the VITE_MINIFY env var into the template.""" + monkeypatch.setenv(environment.VITE_MINIFY.name, "true" if minify else "false") + config = frontend_skeleton._compile_vite_config(prerequisites.get_config()) + assert f"minify: {'true' if minify else 'false'}," in config