Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/6663.feature.md
Original file line number Diff line number Diff line change
@@ -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`).
1 change: 1 addition & 0 deletions packages/reflex-base/news/6663.feature.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
const noVendorPrefix = /^(1|true)$/i.test(
process.env.REFLEX_NO_AUTOPREFIXER ?? "",
);

export default {
plugins: {
"postcss-import": {},
autoprefixer: {},
autoprefixer: noVendorPrefix ? false : {},
},
};
4 changes: 4 additions & 0 deletions packages/reflex-base/src/reflex_base/compiler/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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) {{
Expand Down
1 change: 1 addition & 0 deletions packages/reflex-base/src/reflex_base/constants/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ class Env(str, Enum):
"""The environment modes."""

DEV = "dev"
PREVIEW = "preview"
PROD = "prod"


Expand Down
6 changes: 6 additions & 0 deletions packages/reflex-base/src/reflex_base/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
22 changes: 21 additions & 1 deletion reflex/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
79 changes: 73 additions & 6 deletions reflex/reflex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand All @@ -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()

Expand Down Expand Up @@ -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()
Expand All @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.",
)
Expand Down
1 change: 1 addition & 0 deletions reflex/utils/frontend_skeleton.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
51 changes: 51 additions & 0 deletions tests/units/utils/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading