diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index 3c525e99f4e..e3b839b79be 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -2,19 +2,26 @@ from __future__ import annotations +import copy import json +import re from collections.abc import Iterable, Mapping -from typing import TYPE_CHECKING, Any, Literal +from string import Template +from typing import TYPE_CHECKING, Any, Final, Literal from reflex_base import constants +from reflex_base.compiler.utils import compile_imports from reflex_base.constants import Hooks +from reflex_base.constants.vite import ViteConfigDict from reflex_base.utils.format import format_state_name, json_dumps -from reflex_base.vars.base import VarData +from reflex_base.utils.imports import ImportVar, parse_imports +from reflex_base.vars.base import Var, VarData if TYPE_CHECKING: - from reflex.compiler.utils import _ImportDict from reflex_base.components.component import Component + from .utils import ImportDict + def _sort_hooks( hooks: dict[str, VarData | None], @@ -106,7 +113,7 @@ def render_match_tag(component: Any) -> str: }})()""" @staticmethod - def get_import(module: _ImportDict) -> str: + def get_import(module: ImportDict) -> str: default_import = module["default"] rest_imports = module["rest"] @@ -121,6 +128,245 @@ def get_import(module: _ImportDict) -> str: return f'import "{module["lib"]}"' +class ViteConfig: + """Vite Config Renderer.""" + + _VITE_CONFIG_TEMPLATE = Template( + """$imports + +$functions + +export default defineConfig((config) => ($config +)); +""" + ) + _ALWAYS_USE_REACT_DOM_SERVER_NODE: Final = """ +// Ensure that bun always uses the react-dom/server.node functions. +function alwaysUseReactDomServerNode() { + return { + name: "vite-plugin-always-use-react-dom-server-node", + enforce: "pre", + + resolveId(source, importer) { + if ( + typeof importer === "string" && + importer.endsWith("/entry.server.node.tsx") && + source.includes("react-dom/server") + ) { + return this.resolve("react-dom/server.node", importer, { + skipSelf: true, + }); + } + return null; + }, + }; +} +""" + _FULL_RELOAD: Final = """ +function fullReload() { + return { + name: "full-reload", + enforce: "pre", + handleHotUpdate({ server }) { + server.ws.send({ + type: "full-reload", + }); + return []; + } + }; +} +""" + _ON_WARN: Final = """onwarn(warning, warn) { + if (warning.code === "EVAL" && warning.id && warning.id.endsWith("state.js")) return; + warn(warning); +}""" + + def __init__( + self, + *, + base: str, + sourcemap: Literal["inline", "hidden"] | bool, + experimental_hmr: bool, + hmr: bool, + force_full_reload: bool, + allowed_hosts: bool | list[str], + ) -> None: + """Initialize the Vite Config Renderer.""" + self.imports = { + "url": [ + ImportVar(tag="fileURLToPath"), + ImportVar(tag="URL"), + ], + "@react-router": ImportVar(tag="reactRouter", package_path="/dev/vite"), + "vite": ImportVar(tag="defineConfig"), + "./vite-plugin-safari-cachebust": ImportVar( + tag="safariCacheBustPlugin", is_default=True + ), + } + self.functions = [ + Var(self._ALWAYS_USE_REACT_DOM_SERVER_NODE), + Var(self._FULL_RELOAD), + ] + self.default_config: ViteConfigDict = { + "base": base, + "plugins": [ + Var("alwaysUseReactDomServerNode()"), + Var("reactRouter()"), + Var("safariCacheBustPlugin()"), + ], + "build": { + "sourcemap": sourcemap, + "rollupOptions": { + "": Var(self._ON_WARN), + "jsx": {}, + "output": { + "advancedChunks": { + "groups": [ + {"test": Var("/env.json/"), "name": "reflex-env"} + ], + }, + }, + }, + }, + "experimental": { + "enableNativePlugin": False, + "hmr": experimental_hmr, + }, + "server": { + "port": Var("process.env.PORT"), + "hmr": hmr, + "watch": { + "ignored": [ + "**/.web/backend/**", + "**/.web/reflex.install_frontend_packages.cached", + ], + }, + }, + "resolve": { + "mainFields": ["browser", "module", "jsnext"], + "alias": [ + { + "find": "$", + "replacement": Var( + "fileURLToPath(new URL('./', import.meta.url))" + ), + }, + { + "find": "@", + "replacement": Var( + "fileURLToPath(new URL('./public', import.meta.url))" + ), + }, + ], + }, + } + if force_full_reload: + self.default_config["plugins"].append(Var("fullReload()")) + if allowed_hosts is not False: + self.default_config["server"]["allowedHosts"] = allowed_hosts + + def _deep_merge( + self, mergee: ViteConfigDict | dict, merger: ViteConfigDict + ) -> ViteConfigDict: + """Deep merge two Vite configuration dictionaries. + + Args: + mergee: The source configuration to merge from. + merger: The target configuration to merge into, overwriting values. + + Returns: + The merged configuration dictionary. + """ + for k, v in mergee.items(): + if isinstance(v, dict) and isinstance(merger.get(k), dict): + merger[k] = self._deep_merge(v, merger[k]) + elif isinstance(v, list): + if k in merger: + merger[k].extend(v) + else: + merger[k] = v + else: + merger[k] = v + return merger + + def _handle_dict(self, value: dict, sp: str, indent: int) -> str: + """Helper method to handle dictionary conversion to JavaScript object. + + Returns: + The Python dict as a JS object string. + """ + if set(value.keys()) and all(isinstance(k, str) for k in value): + items = [] + for k, v in value.items(): + if k == "": + if not isinstance(v, Var): + msg = ( + "An empty dict key can only be set to a Var for rendering." + ) + raise RuntimeError(msg) + items.append(f"{sp} {self._render(v, indent + 1)}") + continue + key = k if re.match(r"^[A-Za-z_$][A-Za-z0-9_$]*$", k) else f"'{k}'" + items.append(f"{sp} {key}: {self._render(v, indent + 1)}") + return "{\n" + ",\n".join(items) + f"\n{sp}" + "}" + return "{}" + + def _render(self, value: Any, indent: int = 0) -> str: + """Convert a Python value to JavaScript literal syntax. + + Returns: + The rendered `vite.config.js` content. + """ + sp = " " * indent + + type_handlers = { + Var: lambda v: v._js_expr, + dict: lambda v: self._handle_dict(v, sp, indent), + list: lambda v: ( + f"[{', '.join(self._render(item, indent + 1) for item in v)}]" + ), + str: lambda v: f"'{v}'", + bool: lambda v: "true" if v else "false", + type(None): lambda _: "null", + } + + for value_type, handler in type_handlers.items(): + if isinstance(value, value_type): + return handler(value) + + # Numeric / fallback + return str(value) + + def render(self, vite_config: ViteConfigDict | None) -> str: + """Render the Vite config content. + + Returns: + The `vite.config.js` file content. + """ + if vite_config: + vite_config = copy.deepcopy(vite_config) + if imports := vite_config.pop("imports", None): + self.imports.update(imports) + + if functions := vite_config.pop("functions", None): + self.functions.extend(functions) + + imports = parse_imports(self.imports) + imports_list = compile_imports(imports) + config_dict = ( + self._deep_merge(vite_config, self.default_config) + if vite_config + else self.default_config + ) + config = self._render(config_dict) + + return self._VITE_CONFIG_TEMPLATE.safe_substitute( + imports="\n".join([_RenderUtils.get_import(imp) for imp in imports_list]), + functions="\n".join([function._js_expr for function in self.functions]), + config=config, + ) + + def rxconfig_template(app_name: str): """Template for the Reflex config file. @@ -141,7 +387,7 @@ def rxconfig_template(app_name: str): )""" -def document_root_template(*, imports: list[_ImportDict], document: dict[str, Any]): +def document_root_template(*, imports: list[ImportDict], document: dict[str, Any]): """Template for the document root. Args: @@ -163,7 +409,7 @@ def document_root_template(*, imports: list[_ImportDict], document: dict[str, An def app_root_template( *, - imports: list[_ImportDict], + imports: list[ImportDict], custom_codes: Iterable[str], hooks: dict[str, VarData | None], window_libraries: list[tuple[str, str]], @@ -435,7 +681,7 @@ def component_template(component: Component): def page_template( - imports: Iterable[_ImportDict], + imports: Iterable[ImportDict], dynamic_imports: Iterable[str], custom_codes: Iterable[str], hooks: dict[str, VarData | None], @@ -506,6 +752,7 @@ def vite_config_template( force_full_reload: bool, experimental_hmr: bool, sourcemap: bool | Literal["inline", "hidden"], + vite_config: ViteConfigDict | None, allowed_hosts: bool | list[str] = False, ): """Template for vite.config.js. @@ -516,111 +763,20 @@ 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. + vite_config: The user's optionally defined Vite config. allowed_hosts: Allow all hosts (True), specific hosts (list of strings), or only localhost (False). Returns: Rendered vite.config.js content as string. """ - if allowed_hosts is True: - allowed_hosts_line = "\n allowedHosts: true," - elif isinstance(allowed_hosts, list) and allowed_hosts: - allowed_hosts_line = f"\n allowedHosts: {json.dumps(allowed_hosts)}," - else: - allowed_hosts_line = "" - return rf"""import {{ fileURLToPath, URL }} from "url"; -import {{ reactRouter }} from "@react-router/dev/vite"; -import {{ defineConfig }} from "vite"; -import safariCacheBustPlugin from "./vite-plugin-safari-cachebust"; - -// Ensure that bun always uses the react-dom/server.node functions. -function alwaysUseReactDomServerNode() {{ - return {{ - name: "vite-plugin-always-use-react-dom-server-node", - enforce: "pre", - - resolveId(source, importer) {{ - if ( - typeof importer === "string" && - importer.endsWith("/entry.server.node.tsx") && - source.includes("react-dom/server") - ) {{ - return this.resolve("react-dom/server.node", importer, {{ - skipSelf: true, - }}); - }} - return null; - }}, - }}; -}} - -function fullReload() {{ - return {{ - name: "full-reload", - enforce: "pre", - handleHotUpdate({{ server }}) {{ - server.ws.send({{ - type: "full-reload", - }}); - return []; - }} - }}; -}} - -export default defineConfig((config) => ({{ - base: "{base}", - plugins: [ - alwaysUseReactDomServerNode(), - reactRouter(), - safariCacheBustPlugin(), - ].concat({"[fullReload()]" if force_full_reload else "[]"}), - build: {{ - sourcemap: {"true" if sourcemap is True else "false" if sourcemap is False else repr(sourcemap)}, - rollupOptions: {{ - onwarn(warning, warn) {{ - if (warning.code === "EVAL" && warning.id && warning.id.endsWith("state.js")) return; - warn(warning); - }}, - jsx: {{}}, - output: {{ - advancedChunks: {{ - groups: [ - {{ - test: /env.json/, - name: "reflex-env", - }}, - ], - }}, - }}, - }}, - }}, - experimental: {{ - enableNativePlugin: false, - hmr: {"true" if experimental_hmr else "false"}, - }}, - server: {{ - port: process.env.PORT,{allowed_hosts_line} - hmr: {"true" if hmr else "false"}, - watch: {{ - ignored: [ - "**/.web/backend/**", - "**/.web/reflex.install_frontend_packages.cached", - ], - }}, - }}, - resolve: {{ - mainFields: ["browser", "module", "jsnext"], - alias: [ - {{ - find: "$", - replacement: fileURLToPath(new URL("./", import.meta.url)), - }}, - {{ - find: "@", - replacement: fileURLToPath(new URL("./public", import.meta.url)), - }}, - ], - }}, -}}));""" + return ViteConfig( + base=base, + hmr=hmr, + force_full_reload=force_full_reload, + experimental_hmr=experimental_hmr, + sourcemap=sourcemap, + allowed_hosts=allowed_hosts, + ).render(vite_config=vite_config) def dynamic_component_template( @@ -648,7 +804,7 @@ def dynamic_component_template( def dynamic_components_module_template( - imports: list[_ImportDict], memoized_code: str + imports: list[ImportDict], memoized_code: str ) -> str: """Template for a dynamic-SSR components module. @@ -664,7 +820,7 @@ def dynamic_components_module_template( def memo_components_template( - imports: list[_ImportDict], + imports: list[ImportDict], components: list[dict[str, Any]], functions: list[dict[str, Any]], dynamic_imports: Iterable[str], @@ -716,7 +872,7 @@ def memo_components_template( def memo_single_component_template( - imports: list[_ImportDict], + imports: list[ImportDict], component: dict[str, Any], dynamic_imports: Iterable[str], custom_codes: Iterable[str], @@ -756,7 +912,7 @@ def memo_single_component_template( def memo_single_function_template( - imports: list[_ImportDict], + imports: list[ImportDict], function: dict[str, Any], ) -> str: """Template for a single function memo in its own module. diff --git a/packages/reflex-base/src/reflex_base/compiler/utils.py b/packages/reflex-base/src/reflex_base/compiler/utils.py new file mode 100644 index 00000000000..8c125b44604 --- /dev/null +++ b/packages/reflex-base/src/reflex_base/compiler/utils.py @@ -0,0 +1,150 @@ +"""Common utility functions used in the compiler.""" + +from typing import TypedDict + +from reflex_base.utils import format, imports + + +def validate_imports(import_dict: imports.ParsedImportDict): + """Verify that the same Tag is not used in multiple import. + + Args: + import_dict: The dict of imports to validate + + Raises: + ValueError: if a conflict on "tag/alias" is detected for an import. + """ + used_tags = {} + for lib, imported_items in import_dict.items(): + for imported_item in imported_items: + import_name = ( + f"{imported_item.tag}/{imported_item.alias}" + if imported_item.alias + else imported_item.tag + ) + if import_name in used_tags: + already_imported = used_tags[import_name] + if (already_imported[0] == "$" and already_imported[1:] == lib) or ( + lib[0] == "$" and lib[1:] == already_imported + ): + used_tags[import_name] = lib if lib[0] == "$" else already_imported + continue + msg = f"Can not compile, the tag {import_name} is used multiple time from {lib} and {used_tags[import_name]}" + raise ValueError(msg) + if import_name is not None: + used_tags[import_name] = lib + + +def compile_import_statement(fields: list[imports.ImportVar]) -> tuple[str, list[str]]: + """Compile an import statement. + + Args: + fields: The set of fields to import from the library. + + Returns: + The libraries for default and rest. + default: default library. When install "import def from library". + rest: rest of libraries. When install "import {rest1, rest2} from library" + + Raises: + ValueError: If there is more than one default import. + """ + # ignore the ImportVar fields with render=False during compilation + fields_set = {field for field in fields if field.render} + + # Check for default imports. + defaults = {field for field in fields_set if field.is_default} + if len(defaults) >= 2: + msg = "Only one default import is allowed." + raise ValueError(msg) + + # Get the default import, and the specific imports. + default = next(iter({field.name for field in defaults}), "") + rest = {field.name for field in fields_set - defaults} + + return default, sorted(rest) + + +class ImportDict(TypedDict): + """TypedDict for compiled import information. + + Attributes: + lib: The library name. + default: The default import name. + rest: List of non-default import names. + """ + + lib: str + default: str + rest: list[str] + + +def compile_imports(import_dict: imports.ParsedImportDict) -> list[ImportDict]: + """Compile an import dict. + + Args: + import_dict: The import dict to compile. + + Returns: + The list of import dict. + + Raises: + ValueError: If an import in the dict is invalid. + """ + collapsed_import_dict: imports.ParsedImportDict = imports.collapse_imports( + import_dict + ) + validate_imports(collapsed_import_dict) + import_dicts: list[ImportDict] = [] + for lib, fields in collapsed_import_dict.items(): + # prevent lib from being rendered on the page if all imports are non rendered kind + if not any(f.render for f in fields): + continue + + lib_paths: dict[str, list[imports.ImportVar]] = {} + + for field in fields: + lib_paths.setdefault(field.package_path, []).append(field) + + compiled = { + path: compile_import_statement(fields) for path, fields in lib_paths.items() + } + + for path, (default, rest) in compiled.items(): + if not lib: + if default: + msg = "No default field allowed for empty library." + raise ValueError(msg) + if rest is None or len(rest) == 0: + msg = "No fields to import." + raise ValueError(msg) + import_dicts.extend(get_import_dict(module) for module in sorted(rest)) + continue + + # remove the version before rendering the package imports + formatted_lib = format.format_library_name(lib) + ( + path if path != "/" else "" + ) + + import_dicts.append(get_import_dict(formatted_lib, default, rest)) + return import_dicts + + +def get_import_dict( + lib: str, default: str = "", rest: list[str] | None = None +) -> ImportDict: + """Get dictionary for import template. + + Args: + lib: The importing react library. + default: The default module to import. + rest: The rest module to import. + + Returns: + A dictionary for import template. + """ + return ImportDict( + lib=lib, + default=default, + rest=rest or [], + ) diff --git a/packages/reflex-base/src/reflex_base/components/dynamic.py b/packages/reflex-base/src/reflex_base/components/dynamic.py index a668bd341fc..7edd59935e7 100644 --- a/packages/reflex-base/src/reflex_base/components/dynamic.py +++ b/packages/reflex-base/src/reflex_base/components/dynamic.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Union from reflex_base import constants +from reflex_base.compiler.utils import compile_imports from reflex_base.utils import imports from reflex_base.utils.exceptions import DynamicComponentMissingLibraryError from reflex_base.utils.format import format_library_name @@ -78,7 +79,7 @@ def make_component(component: Component) -> str: # Causes a circular import, so we import here. from reflex_components_core.base.bare import Bare - from reflex.compiler import compiler, templates, utils + from reflex.compiler import compiler, templates component = Bare.create(Var.create(component)) @@ -116,7 +117,7 @@ def make_component(component: Component) -> str: imports[lib] = names module_code_lines = templates.dynamic_components_module_template( - imports=utils.compile_imports(imports), + imports=compile_imports(imports), memoized_code="\n".join(rendered_components), ).splitlines() diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index ab005375ebf..3eded1523d7 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -14,6 +14,7 @@ from reflex_base import constants from reflex_base.constants.base import LogLevel +from reflex_base.constants.vite import ViteConfigDict from reflex_base.environment import EnvironmentVariables as EnvironmentVariables from reflex_base.environment import EnvVar as EnvVar from reflex_base.environment import ( @@ -166,7 +167,7 @@ class BaseConfig: bun_path: The bun path. static_page_generation_timeout: Timeout to do a production build of a frontend page. cors_allowed_origins: Comma separated list of origins that are allowed to connect to the backend API. - vite_allowed_hosts: Allowed hosts for the Vite dev server. Set to True to allow all hosts, or provide a list of hostnames (e.g. ["myservice.local"]) to allow specific ones. Prevents 403 errors in Docker, Codespaces, reverse proxies, etc. + vite_config: A user-defined Vite config that will get deeply merged with Reflex's Vite config, allowing for customization and overriding of Reflex defaults. react_strict_mode: Whether to use React strict mode. frontend_packages: Additional frontend packages to install. state_manager_mode: Indicate which type of state manager to use. @@ -222,6 +223,8 @@ class BaseConfig: vite_allowed_hosts: bool | list[str] = False + vite_config: ViteConfigDict | None = None + react_strict_mode: bool = True frontend_packages: list[str] = dataclasses.field(default_factory=list) @@ -373,6 +376,14 @@ def _post_init(self, **kwargs): removal_version="1.0", ) + if "vite_allowed_hosts" in kwargs and kwargs["vite_allowed_hosts"] is not False: + console.deprecate( + feature_name="vite_allowed_hosts", + reason="Use vite_config={'server': {'allowedHosts': ...}} instead.", + deprecation_version="0.9.3", + removal_version="1.0", + ) + # Update default URLs if ports were set kwargs.update(env_kwargs) self._non_default_attributes = set(kwargs.keys()) diff --git a/packages/reflex-base/src/reflex_base/constants/vite.py b/packages/reflex-base/src/reflex_base/constants/vite.py new file mode 100644 index 00000000000..d5943957f9e --- /dev/null +++ b/packages/reflex-base/src/reflex_base/constants/vite.py @@ -0,0 +1,396 @@ +"""Vite Config constants.""" + +from collections.abc import Sequence +from typing import Any, Literal, TypedDict + +from reflex_base.utils.imports import ImportVar +from reflex_base.vars.base import Var + + +class Alias(TypedDict): + """Configuration for module path aliases in Vite. + + Attributes: + find: The module path pattern to find and replace. + replacement: The replacement path for the matched pattern. + """ + + find: str | Var + replacement: str | Var + + +class Resolve(TypedDict, total=False): + """Configuration options for Vite module resolution. + + Attributes: + alias: List of module path aliases for resolution. + dedupe: List of packages to dedupe or raw JS configuration. + conditions: List of export conditions for module resolution. + mainFields: List of main fields to check during resolution. + extensions: List of file extensions to resolve. + preserveSymlinks: Whether to preserve symbolic links during resolution. + """ + + alias: list[Alias] + dedupe: list[str] | Var + conditions: list[str] | Var + mainFields: list[str] | Var + extensions: list[str] | Var + preserveSymlinks: bool | Var + + +class HTML(TypedDict, total=False): + """Configuration options for HTML handling in Vite. + + Attributes: + cspNonce: Content Security Policy nonce for inline scripts and styles. + """ + + cspNonce: str | Var + + +class CSS(TypedDict, total=False): + """Configuration options for CSS handling in Vite. + + Attributes: + postcss: PostCSS configuration or plugin path. + preprocessorOptions: Options for CSS preprocessors like Sass, Less, etc. + preprocessorMaxWorkers: Maximum number of preprocessor workers or True for auto. + """ + + postcss: str | Var + preprocessorOptions: dict[str, Any] | Var + preprocessorMaxWorkers: int | Literal[True] | Var + + +class Json(TypedDict, total=False): + """Configuration options for JSON handling in Vite. + + Attributes: + namedExports: Whether to enable named exports for JSON imports. + stringify: Whether to stringify JSON or use automatic handling. + """ + + namedExports: bool | Var + stringify: bool | Literal["auto"] | Var + + +class HTTPSOptions(TypedDict): + """Configuration options for HTTPS settings. + + Attributes: + key: The private key for HTTPS configuration. + cert: The certificate for HTTPS configuration. + """ + + key: str | Var + cert: str | Var + + +class HMROptions(TypedDict, total=False): + """Configuration options for Vite Hot Module Replacement (HMR). + + Attributes: + protocol: The protocol to use for HMR connection. + host: The host for HMR server. + port: The port for HMR server. + path: The path for HMR WebSocket connection. + timeout: Timeout for HMR connection in milliseconds. + overlay: Whether to show HMR error overlay. + clientPort: The port for HMR client connection. + """ + + protocol: str | Var + host: str | Var + port: int | Var + path: str | Var + timeout: int | Var + overlay: bool | Var + clientPort: int | Var + + +class WarmupOptions(TypedDict, total=False): + """Configuration options for Vite warmup settings. + + Attributes: + clientFiles: List of client files to warmup. + ssrFiles: List of SSR files to warmup. + """ + + clientFiles: list[str] | Var + ssrFiles: list[str] | Var + + +class ServerFsOptions(TypedDict, total=False): + """Configuration options for Vite server file system settings. + + Attributes: + strict: Whether to enforce strict file system rules. + allow: List of paths that are allowed to be served. + deny: List of paths that are denied from being served. + """ + + strict: bool | Var + allow: list[str] | Var + deny: list[str] | Var + + +class Server(TypedDict, total=False): + """Configuration options for Vite development server. + + Attributes: + host: The host to bind the server to. + allowedHosts: List of allowed hosts or True to allow all. + port: The port number for the development server. + strictPort: Whether to use strict port binding. + https: HTTPS configuration options. + open: Whether to open browser or specify URL to open. + proxy: Proxy configuration for the development server. + cors: CORS configuration settings. + headers: Custom headers to send with responses. + hmr: Hot Module Replacement configuration. + warmup: Warmup configuration options. + watch: File watching configuration. + middlewareMode: Whether to run in middleware mode. + fs: File system configuration options. + origin: Origin URL for the server. + sourcemapIgnoreList: Sourcemap ignore list configuration. + """ + + host: str | bool | Var + allowedHosts: list[str] | bool | Var + port: int | Var + strictPort: bool | Var + https: HTTPSOptions + open: bool | str | Var + proxy: dict[str, Any] | Var + cors: bool | dict[str, Any] | Var + headers: dict[str, str] | Var + hmr: bool | HMROptions + warmup: WarmupOptions + watch: dict[str, Any] | Var | None + middlewareMode: bool | Var + fs: ServerFsOptions + origin: str | Var + sourcemapIgnoreList: Literal[False] | Var + + +class ModulePreloadOptions(TypedDict, total=False): + """Configuration options for Vite module preload settings. + + Attributes: + polyfill: Whether to polyfill module preload functionality. + resolveDependencies: Custom function for resolving dependencies. + """ + + polyfill: bool | Var + resolveDependencies: Var + + +class BuildLibOptions(TypedDict, total=False): + """Configuration options for Vite library build settings. + + Attributes: + entry: Entry point(s) for the library build. + name: Name of the library for UMD/IIFE builds. + formats: Output formats for the library build. + fileName: Custom filename pattern for output files. + cssFileName: Custom filename pattern for CSS files. + """ + + entry: str | list[str] | Var + name: str | Var + formats: list[Literal["es", "cjs", "umd", "iife"]] | Var + fileName: str | Var + cssFileName: str | Var + + +class BuildOptions(TypedDict, total=False): + """Configuration options for Vite build settings. + + Attributes: + target: Build target(s) for the output bundle. + modulePreload: Module preload configuration options. + polyfillModulePreload: Whether to polyfill module preload. + outDir: Output directory for build files. + assetsDir: Directory for static assets within outDir. + assetsInlineLimit: Size limit for inlining assets as base64. + cssCodeSplit: Whether to enable CSS code splitting. + cssTarget: CSS build target(s). + cssMinify: CSS minification method or boolean. + sourcemap: Sourcemap generation options. + rollupOptions: Additional Rollup configuration. + commonjsOptions: CommonJS plugin options. + dynamicImportVarsOptions: Dynamic import variables options. + lib: Library build configuration. + manifest: Whether to generate build manifest. + ssrManifest: Whether to generate SSR manifest. + ssr: SSR build configuration. + emitAssets: Whether to emit assets during build. + ssrEmitAssets: Whether to emit assets during SSR build. + minify: Minification method or boolean. + terserOptions: Terser minification options. + write: Whether to write files to disk. + emptyOutDir: Whether to empty output directory before build. + copyPublicDir: Whether to copy public directory. + reportCompressedSize: Whether to report compressed bundle sizes. + chunkSizeWarningLimit: Warning threshold for chunk sizes in bytes. + watch: File watching configuration for build mode. + """ + + target: str | list[str] | Var + modulePreload: bool | ModulePreloadOptions | Var + polyfillModulePreload: bool | Var + outDir: str | Var + assetsDir: str | Var + assetsInlineLimit: int | Var + cssCodeSplit: bool | Var + cssTarget: str | list[str] | Var + cssMinify: bool | Literal["esbuild", "lightningcss"] | Var + sourcemap: bool | Literal["inline", "hidden"] | Var + rollupOptions: dict[str, Any] | Var + commonjsOptions: dict[str, Any] | Var + dynamicImportVarsOptions: dict[str, Any] | Var + lib: BuildLibOptions + manifest: bool | str | Var + ssrManifest: bool | str | Var + ssr: bool | str | Var + emitAssets: bool | Var + ssrEmitAssets: bool | Var + minify: bool | Literal["terser", "esbuild"] | Var + terserOptions: dict[str, Any] | Var + write: bool | Var + emptyOutDir: bool | Var + copyPublicDir: bool | Var + reportCompressedSize: bool | Var + chunkSizeWarningLimit: int | Var + watch: dict[str, Any] | Var | None + + +class PreviewOptions(TypedDict, total=False): + """Configuration options for Vite preview server. + + Attributes: + host: The host to bind the preview server to. + allowedHosts: List of allowed hosts or True to allow all. + port: The port number for the preview server. + strictPort: Whether to use strict port binding. + https: HTTPS configuration options. + open: Whether to open browser or specify URL to open. + proxy: Proxy configuration for the preview server. + cors: CORS configuration settings. + headers: Custom headers to send with responses. + """ + + host: str | bool | Var + allowedHosts: list[str] | Literal[True] | Var + port: int | Var + strictPort: bool | Var + https: HTTPSOptions + open: bool | str | Var + proxy: dict[str, Any] | Var + cors: bool | dict[str, Any] | Var + headers: dict[str, str] | Var + + +class OptimizeDepsOptions(TypedDict, total=False): + """Configuration options for Vite dependency optimization. + + Attributes: + entries: Entry points for dependency optimization. + exclude: Dependencies to exclude from optimization. + include: Dependencies to include in optimization. + esbuildOptions: Additional esbuild configuration options. + force: Whether to force re-optimization of dependencies. + noDiscovery: Whether to disable automatic dependency discovery. + holdUntilCrawlEnd: Whether to hold optimization until crawl completion. + disabled: Whether to disable dependency optimization entirely. + """ + + entries: str | list[str] | Var + exclude: list[str] | Var + include: list[str] | Var + esbuildOptions: dict[str, Any] | Var + force: bool | Var + noDiscovery: bool | Var + holdUntilCrawlEnd: bool | Var + disabled: bool | Literal["build", "dev"] | Var + + +class SSRResolveOptions(TypedDict, total=False): + """Configuration options for SSR module resolution in Vite. + + Attributes: + conditions: List of conditions for module resolution. + externalConditions: List of external conditions for module resolution. + mainFields: List of main fields to check for module resolution. + """ + + conditions: list[str] | Var + externalConditions: list[str] | Var + mainFields: list[str] | Var + + +class SSROptions(TypedDict, total=False): + """Configuration options for Server-Side Rendering (SSR) in Vite. + + Attributes: + external: External dependencies to be excluded from bundling. + noExternal: Dependencies that should not be externalized. + target: The SSR build target environment. + resolve: SSR-specific module resolution options. + """ + + external: list[str] | bool | Var + noExternal: str | list[str] | Literal[True] | Var + target: Literal["node", "webworker"] + resolve: SSRResolveOptions + + +class WorkerOptions(TypedDict, total=False): + """Configuration options for Vite worker build settings. + + Attributes: + format: The output format for workers ("es" or "iife"). + plugins: Raw JavaScript plugins configuration. + rollupOptions: Additional rollup configuration options. + """ + + format: Literal["es", "iife"] + plugins: Var + rollupOptions: dict[str, Any] + + +class ViteConfigDict(TypedDict, total=False): + """Configuration options for Vite build tool. + + This TypedDict defines the structure for Vite configuration options, + allowing partial specification of build settings, server options, + and other Vite-related configurations. + + Additional imports and user-defined functions can be passed, which are handled explicitly and differently + compared to the rest of the standard Vite config options. + """ + + plugins: list[Var] + root: str | Var + base: str | Var + mode: Literal["development", "production"] | Var + define: dict[str, str | Var] | Var + publicDir: str | Literal[False] | Var + cacheDir: str | Var + resolve: Resolve + html: HTML + css: CSS + json: Json + server: Server + build: BuildOptions + preview: PreviewOptions + optimizeDeps: OptimizeDepsOptions + ssr: SSROptions + worker: WorkerOptions + experimental: dict + + # Additional not defined by Vite + imports: dict[str, ImportVar | Sequence[ImportVar]] + functions: list[Var] diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 39ff4931a9e..016dd052a0e 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any from reflex_base import constants +from reflex_base.compiler.utils import compile_imports from reflex_base.components.component import ( CUSTOM_COMPONENTS, BaseComponent, @@ -109,7 +110,7 @@ def _compile_document_root(root: Component) -> str: document_root_imports = root._get_all_imports() _apply_common_imports(document_root_imports) return templates.document_root_template( - imports=utils.compile_imports(document_root_imports), + imports=compile_imports(document_root_imports), document=root.render(), ) @@ -149,7 +150,7 @@ def _compile_app(app_root: Component) -> str: _apply_common_imports(app_root_imports) return templates.app_root_template( - imports=utils.compile_imports(app_root_imports), + imports=compile_imports(app_root_imports), custom_codes=app_root._get_all_custom_code(), hooks=app_root._get_all_hooks(), window_libraries=window_libraries_deduped, @@ -211,7 +212,7 @@ def _compile_page(component: BaseComponent) -> str: """ imports = component._get_all_imports() _apply_common_imports(imports) - imports = utils.compile_imports(imports) + imports = compile_imports(imports) # Compile the code to render the component. return templates.page_template( @@ -490,7 +491,7 @@ def _compile_single_memo_component( ) _apply_common_imports(imports) code = templates.memo_single_component_template( - imports=utils.compile_imports(imports), + imports=compile_imports(imports), component=component_render, dynamic_imports=sorted(component_render.get("dynamic_imports", []) or []), custom_codes=component_render.get("custom_code", []) or [], @@ -513,7 +514,7 @@ def _compile_single_memo_function( """ imports = utils.merge_imports({}, function_imports) code = templates.memo_single_function_template( - imports=utils.compile_imports(imports), + imports=compile_imports(imports), function=function_render, ) return code, imports @@ -668,7 +669,7 @@ def compile_page_from_context(page_ctx: PageContext) -> tuple[str, str]: _apply_common_imports(imports) code = templates.page_template( - imports=utils.compile_imports(imports), + imports=compile_imports(imports), dynamic_imports=sorted(page_ctx.dynamic_imports), custom_codes=page_ctx.custom_code_dict(), hooks=page_ctx.hooks, diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index c2bdf618650..488e9fbe734 100644 --- a/reflex/compiler/utils.py +++ b/reflex/compiler/utils.py @@ -10,14 +10,14 @@ from collections.abc import Mapping, Sequence from datetime import datetime from pathlib import Path -from typing import Any, TypedDict +from typing import Any from urllib.parse import urlparse from reflex_base import constants from reflex_base.components.component import Component, ComponentStyle, CustomComponent from reflex_base.constants.state import CAMEL_CASE_MEMO_MARKER, FIELD_MARKER from reflex_base.style import Style -from reflex_base.utils import format, imports +from reflex_base.utils import imports from reflex_base.utils.imports import ImportVar, ParsedImportDict from reflex_base.vars.base import Field, Var, VarData from reflex_base.vars.function import DestructuredArg @@ -41,141 +41,6 @@ merge_imports = imports.merge_imports -def compile_import_statement(fields: list[ImportVar]) -> tuple[str, list[str]]: - """Compile an import statement. - - Args: - fields: The set of fields to import from the library. - - Returns: - The libraries for default and rest. - default: default library. When install "import def from library". - rest: rest of libraries. When install "import {rest1, rest2} from library" - - Raises: - ValueError: If there is more than one default import. - """ - # ignore the ImportVar fields with render=False during compilation - fields_set = {field for field in fields if field.render} - - # Check for default imports. - defaults = {field for field in fields_set if field.is_default} - if len(defaults) >= 2: - msg = "Only one default import is allowed." - raise ValueError(msg) - - # Get the default import, and the specific imports. - default = next(iter({field.name for field in defaults}), "") - rest = {field.name for field in fields_set - defaults} - - return default, sorted(rest) - - -def validate_imports(import_dict: ParsedImportDict): - """Verify that the same Tag is not used in multiple import. - - Args: - import_dict: The dict of imports to validate - - Raises: - ValueError: if a conflict on "tag/alias" is detected for an import. - """ - used_tags = {} - for lib, imported_items in import_dict.items(): - for imported_item in imported_items: - import_name = ( - f"{imported_item.tag}/{imported_item.alias}" - if imported_item.alias - else imported_item.tag - ) - if import_name in used_tags: - already_imported = used_tags[import_name] - if (already_imported[0] == "$" and already_imported[1:] == lib) or ( - lib[0] == "$" and lib[1:] == already_imported - ): - used_tags[import_name] = lib if lib[0] == "$" else already_imported - continue - msg = f"Can not compile, the tag {import_name} is used multiple time from {lib} and {used_tags[import_name]}" - raise ValueError(msg) - if import_name is not None: - used_tags[import_name] = lib - - -class _ImportDict(TypedDict): - lib: str - default: str - rest: list[str] - - -def compile_imports(import_dict: ParsedImportDict) -> list[_ImportDict]: - """Compile an import dict. - - Args: - import_dict: The import dict to compile. - - Returns: - The list of import dict. - - Raises: - ValueError: If an import in the dict is invalid. - """ - collapsed_import_dict: ParsedImportDict = imports.collapse_imports(import_dict) - validate_imports(collapsed_import_dict) - import_dicts: list[_ImportDict] = [] - for lib, fields in collapsed_import_dict.items(): - # prevent lib from being rendered on the page if all imports are non rendered kind - if not any(f.render for f in fields): - continue - - lib_paths: dict[str, list[ImportVar]] = {} - - for field in fields: - lib_paths.setdefault(field.package_path, []).append(field) - - compiled = { - path: compile_import_statement(fields) for path, fields in lib_paths.items() - } - - for path, (default, rest) in compiled.items(): - if not lib: - if default: - msg = "No default field allowed for empty library." - raise ValueError(msg) - if rest is None or len(rest) == 0: - msg = "No fields to import." - raise ValueError(msg) - import_dicts.extend(get_import_dict(module) for module in sorted(rest)) - continue - - # remove the version before rendering the package imports - formatted_lib = format.format_library_name(lib) + ( - path if path != "/" else "" - ) - - import_dicts.append(get_import_dict(formatted_lib, default, rest)) - return import_dicts - - -def get_import_dict( - lib: str, default: str = "", rest: list[str] | None = None -) -> _ImportDict: - """Get dictionary for import template. - - Args: - lib: The importing react library. - default: The default module to import. - rest: The rest module to import. - - Returns: - A dictionary for import template. - """ - return _ImportDict( - lib=lib, - default=default, - rest=rest or [], - ) - - def save_error(error: Exception) -> str: """Save the error to a file. diff --git a/reflex/utils/frontend_skeleton.py b/reflex/utils/frontend_skeleton.py index 12054b9a2d3..c1fcd450670 100644 --- a/reflex/utils/frontend_skeleton.py +++ b/reflex/utils/frontend_skeleton.py @@ -298,6 +298,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(), + vite_config=config.vite_config, allowed_hosts=config.vite_allowed_hosts, ) diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index e2f1b769668..7eb29bed739 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -5,6 +5,7 @@ import pytest from pytest_mock import MockerFixture from reflex_base import constants +from reflex_base.compiler.utils import compile_import_statement, compile_imports from reflex_base.components.dynamic import bundle_library, reset_bundled_libraries from reflex_base.constants.compiler import PageNames from reflex_base.utils.imports import ImportVar, ParsedImportDict @@ -53,7 +54,7 @@ def test_compile_import_statement( test_default: The expected output of default library. test_rest: The expected output rest libraries. """ - default, rest = utils.compile_import_statement(fields) + default, rest = compile_import_statement(fields) assert default == test_default assert sorted(rest) == test_rest @@ -111,7 +112,7 @@ def test_compile_imports(import_dict: ParsedImportDict, test_dicts: list[dict]): import_dict: The import dictionary. test_dicts: The expected output. """ - imports = utils.compile_imports(import_dict) + imports = compile_imports(import_dict) for one_import_dict, test_dict in zip(imports, test_dicts, strict=True): assert one_import_dict["lib"] == test_dict["lib"] assert one_import_dict["default"] == test_dict["default"] diff --git a/tests/units/reflex_base/compiler/__init__.py b/tests/units/reflex_base/compiler/__init__.py new file mode 100644 index 00000000000..4143e30bc35 --- /dev/null +++ b/tests/units/reflex_base/compiler/__init__.py @@ -0,0 +1 @@ +"""Unit tests for reflex_base.compiler.""" diff --git a/tests/units/reflex_base/compiler/test_templates.py b/tests/units/reflex_base/compiler/test_templates.py new file mode 100644 index 00000000000..acefca7c5a7 --- /dev/null +++ b/tests/units/reflex_base/compiler/test_templates.py @@ -0,0 +1,101 @@ +"""Tests for compiler templates.""" + +from reflex_base.compiler.templates import vite_config_template +from reflex_base.utils.imports import ImportVar +from reflex_base.vars.base import Var + + +def test_vite_config_template_merges_nested_config(): + """Test custom Vite config values are deeply merged with defaults.""" + output = vite_config_template( + base="/", + hmr=True, + force_full_reload=False, + experimental_hmr=False, + sourcemap=False, + vite_config={ + "build": { + "target": "es2022", + "rollupOptions": {"external": ["react"]}, + }, + "server": { + "allowedHosts": ["test.local"], + "strictPort": True, + }, + }, + ) + + assert "base: '/'" in output + assert "sourcemap: false" in output + assert "target: 'es2022'" in output + assert "onwarn(warning, warn)" in output + assert "external: ['react']" in output + assert "port: process.env.PORT" in output + assert "hmr: true" in output + assert "allowedHosts: ['test.local']" in output + assert "strictPort: true" in output + + +def test_vite_config_template_extends_list_config(): + """Test custom Vite config lists extend Reflex defaults.""" + output = vite_config_template( + base="/", + hmr=True, + force_full_reload=False, + experimental_hmr=False, + sourcemap=False, + vite_config={ + "plugins": [Var("customPlugin()")], + "resolve": { + "alias": [ + { + "find": "~", + "replacement": Var( + "fileURLToPath(new URL('./src', import.meta.url))" + ), + } + ], + }, + }, + ) + + assert ( + "plugins: [alwaysUseReactDomServerNode(), reactRouter(), safariCacheBustPlugin(), customPlugin()]" + in output + ) + assert "find: '$'" in output + assert "find: '@'" in output + assert "find: '~'" in output + assert "replacement: fileURLToPath(new URL('./src', import.meta.url))" in output + + +def test_vite_config_template_renders_custom_imports_and_functions(): + """Test custom imports and functions are rendered outside the config object.""" + output = vite_config_template( + base="/", + hmr=True, + force_full_reload=False, + experimental_hmr=False, + sourcemap=False, + vite_config={ + "imports": { + "vite-plugin-inspect": ImportVar(tag="inspect", is_default=True), + }, + "functions": [ + Var( + """ +function customPlugin() { + return inspect(); +} +""" + ) + ], + "plugins": [Var("customPlugin()")], + }, + ) + + assert 'import inspect from "vite-plugin-inspect"' in output + assert "function customPlugin()" in output + assert "customPlugin()" in output + assert "imports:" not in output + assert "functions:" not in output diff --git a/tests/units/reflex_cli/conftest.py b/tests/units/reflex_cli/conftest.py index f01f3f74a1f..1e5c5fe30c4 100644 --- a/tests/units/reflex_cli/conftest.py +++ b/tests/units/reflex_cli/conftest.py @@ -2,13 +2,18 @@ import pytest from pytest_mock import MockFixture +from reflex_cli.constants.hosting import ReflexHostingCli @pytest.fixture(autouse=True) def mock_check_version(mocker: MockFixture) -> None: - """Bypass the hosting-cli PyPI version check during tests. + """Bypass the hosting-cli context and PyPI version check during tests. The workspace build reports a dev version older than the published one, causing `check_version` to emit a warning and exit(1). """ + mocker.patch( + "reflex_cli.v2.deployments._reflex_version", + ReflexHostingCli.RECOMMENDED_REFLEX_VERSION, + ) mocker.patch("reflex_cli.v2.deployments.check_version") diff --git a/tests/units/test_prerequisites.py b/tests/units/test_prerequisites.py index 0fc6321e3d4..d3ee5bd3e1b 100644 --- a/tests/units/test_prerequisites.py +++ b/tests/units/test_prerequisites.py @@ -167,21 +167,21 @@ def test_update_react_router_config(config, export, expected_output): app_name="test", frontend_path="", ), - 'base: "/",', + "base: '/',", ), ( Config( app_name="test", frontend_path="/test", ), - 'base: "/test/",', + "base: '/test/',", ), ( Config( app_name="test", frontend_path="/test/", ), - 'base: "/test/",', + "base: '/test/',", ), ], ) @@ -190,6 +190,29 @@ def test_initialise_vite_config(config, expected_output): assert expected_output in output +def test_initialise_vite_config_with_custom_config(): + """Test that the configured Vite config is merged into Reflex defaults.""" + output = _compile_vite_config( + Config( + app_name="test", + vite_config={ + "build": {"target": "es2022"}, + "server": { + "allowedHosts": True, + "strictPort": True, + }, + }, + ) + ) + + assert "sourcemap: false" in output + assert "target: 'es2022'" in output + assert "port: process.env.PORT" in output + assert "hmr: true" in output + assert "allowedHosts: true" in output + assert "strictPort: true" in output + + @pytest.mark.usefixtures("_stub_skeleton_initializers") def test_initialize_web_directory_restores_root_bun_lock(tmp_path, monkeypatch): template_dir = tmp_path / "template"