components as literal vars (#4223)

* component as literal vars

* fix pyi

* use render

* fix pyi

* only render once

* add type ignore

* fix upload default value

* remove testcases if you don't pass them

* improve behavior

* fix render

* that's not how icon buttons work

* upgrade to next js 15 and remove babel and enable turbo

* upload is a silly guy

* woops

* how did this work before

* set env variable

* lower it even more

* lower it even more

* lower it even more

* only do literals as component vars
This commit is contained in:
Khaleel Al-Adhami 2024-10-30 11:31:28 -07:00 committed by GitHub
parent c8a7ee52bf
commit 24363170d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 383 additions and 270 deletions

View File

@ -36,14 +36,10 @@
{# component: component dictionary #}
{% macro render_tag(component) %}
<{{component.name}} {{- render_props(component.props) }}>
{%- if component.args is not none -%}
{{- render_arg_content(component) }}
{%- else -%}
{{ component.contents }}
{% for child in component.children %}
{{ render(child) }}
{% endfor %}
{%- endif -%}
{{ component.contents }}
{% for child in component.children %}
{{ render(child) }}
{% endfor %}
</{{component.name}}>
{%- endmacro %}

View File

@ -15,7 +15,6 @@ import {
} from "$/utils/context.js";
import debounce from "$/utils/helpers/debounce";
import throttle from "$/utils/helpers/throttle";
import * as Babel from "@babel/standalone";
// Endpoint URLs.
const EVENTURL = env.EVENT;
@ -139,8 +138,7 @@ export const evalReactComponent = async (component) => {
if (!window.React && window.__reflex) {
window.React = window.__reflex.react;
}
const output = Babel.transform(component, { presets: ["react"] }).code;
const encodedJs = encodeURIComponent(output);
const encodedJs = encodeURIComponent(component);
const dataUri = "data:text/javascript;charset=utf-8," + encodedJs;
const module = await eval(`import(dataUri)`);
return module.default;

View File

@ -4,10 +4,11 @@ from __future__ import annotations
from typing import Any, Iterator
from reflex.components.component import Component
from reflex.components.component import Component, LiteralComponentVar
from reflex.components.tags import Tag
from reflex.components.tags.tagless import Tagless
from reflex.vars import ArrayVar, BooleanVar, ObjectVar, Var
from reflex.utils.imports import ParsedImportDict
from reflex.vars import BooleanVar, ObjectVar, Var
class Bare(Component):
@ -31,9 +32,77 @@ class Bare(Component):
contents = str(contents) if contents is not None else ""
return cls(contents=contents) # type: ignore
def _get_all_hooks_internal(self) -> dict[str, None]:
"""Include the hooks for the component.
Returns:
The hooks for the component.
"""
hooks = super()._get_all_hooks_internal()
if isinstance(self.contents, LiteralComponentVar):
hooks |= self.contents._var_value._get_all_hooks_internal()
return hooks
def _get_all_hooks(self) -> dict[str, None]:
"""Include the hooks for the component.
Returns:
The hooks for the component.
"""
hooks = super()._get_all_hooks()
if isinstance(self.contents, LiteralComponentVar):
hooks |= self.contents._var_value._get_all_hooks()
return hooks
def _get_all_imports(self) -> ParsedImportDict:
"""Include the imports for the component.
Returns:
The imports for the component.
"""
imports = super()._get_all_imports()
if isinstance(self.contents, LiteralComponentVar):
var_data = self.contents._get_all_var_data()
if var_data:
imports |= {k: list(v) for k, v in var_data.imports}
return imports
def _get_all_dynamic_imports(self) -> set[str]:
"""Get dynamic imports for the component.
Returns:
The dynamic imports.
"""
dynamic_imports = super()._get_all_dynamic_imports()
if isinstance(self.contents, LiteralComponentVar):
dynamic_imports |= self.contents._var_value._get_all_dynamic_imports()
return dynamic_imports
def _get_all_custom_code(self) -> set[str]:
"""Get custom code for the component.
Returns:
The custom code.
"""
custom_code = super()._get_all_custom_code()
if isinstance(self.contents, LiteralComponentVar):
custom_code |= self.contents._var_value._get_all_custom_code()
return custom_code
def _get_all_refs(self) -> set[str]:
"""Get the refs for the children of the component.
Returns:
The refs for the children.
"""
refs = super()._get_all_refs()
if isinstance(self.contents, LiteralComponentVar):
refs |= self.contents._var_value._get_all_refs()
return refs
def _render(self) -> Tag:
if isinstance(self.contents, Var):
if isinstance(self.contents, (BooleanVar, ObjectVar, ArrayVar)):
if isinstance(self.contents, (BooleanVar, ObjectVar)):
return Tagless(contents=f"{{{str(self.contents.to_string())}}}")
return Tagless(contents=f"{{{str(self.contents)}}}")
return Tagless(contents=str(self.contents))

View File

@ -3,6 +3,7 @@
from __future__ import annotations
import copy
import dataclasses
import typing
from abc import ABC, abstractmethod
from functools import lru_cache, wraps
@ -59,7 +60,15 @@ from reflex.utils.imports import (
parse_imports,
)
from reflex.vars import VarData
from reflex.vars.base import LiteralVar, Var
from reflex.vars.base import (
CachedVarOperation,
LiteralVar,
Var,
cached_property_no_lock,
)
from reflex.vars.function import ArgsFunctionOperation, FunctionStringVar
from reflex.vars.number import ternary_operation
from reflex.vars.object import ObjectVar
from reflex.vars.sequence import LiteralArrayVar
@ -2345,3 +2354,203 @@ class MemoizationLeaf(Component):
load_dynamic_serializer()
class ComponentVar(Var[Component], python_types=BaseComponent):
"""A Var that represents a Component."""
def empty_component() -> Component:
"""Create an empty component.
Returns:
An empty component.
"""
from reflex.components.base.bare import Bare
return Bare.create("")
def render_dict_to_var(tag: dict | Component | str, imported_names: set[str]) -> Var:
"""Convert a render dict to a Var.
Args:
tag: The render dict.
imported_names: The names of the imported components.
Returns:
The Var.
"""
if not isinstance(tag, dict):
if isinstance(tag, Component):
return render_dict_to_var(tag.render(), imported_names)
return Var.create(tag)
if "iterable" in tag:
function_return = Var.create(
[
render_dict_to_var(child.render(), imported_names)
for child in tag["children"]
]
)
func = ArgsFunctionOperation.create(
(tag["arg_var_name"], tag["index_var_name"]),
function_return,
)
return FunctionStringVar.create("Array.prototype.map.call").call(
tag["iterable"]
if not isinstance(tag["iterable"], ObjectVar)
else tag["iterable"].items(),
func,
)
if tag["name"] == "match":
element = tag["cond"]
conditionals = tag["default"]
for case in tag["match_cases"][::-1]:
condition = case[0].to_string() == element.to_string()
for pattern in case[1:-1]:
condition = condition | (pattern.to_string() == element.to_string())
conditionals = ternary_operation(
condition,
case[-1],
conditionals,
)
return conditionals
if "cond" in tag:
return ternary_operation(
tag["cond"],
render_dict_to_var(tag["true_value"], imported_names),
render_dict_to_var(tag["false_value"], imported_names)
if tag["false_value"] is not None
else Var.create(None),
)
props = {}
special_props = []
for prop_str in tag["props"]:
if "=" not in prop_str:
special_props.append(Var(prop_str).to(ObjectVar))
continue
prop = prop_str.index("=")
key = prop_str[:prop]
value = prop_str[prop + 2 : -1]
props[key] = value
props = Var.create({Var.create(k): Var(v) for k, v in props.items()})
for prop in special_props:
props = props.merge(prop)
contents = tag["contents"][1:-1] if tag["contents"] else None
raw_tag_name = tag.get("name")
tag_name = Var(raw_tag_name or "Fragment")
tag_name = (
Var.create(raw_tag_name)
if raw_tag_name
and raw_tag_name.split(".")[0] not in imported_names
and raw_tag_name.lower() == raw_tag_name
else tag_name
)
return FunctionStringVar.create(
"jsx",
).call(
tag_name,
props,
*([Var(contents)] if contents is not None else []),
*[render_dict_to_var(child, imported_names) for child in tag["children"]],
)
@dataclasses.dataclass(
eq=False,
frozen=True,
)
class LiteralComponentVar(CachedVarOperation, LiteralVar, ComponentVar):
"""A Var that represents a Component."""
_var_value: BaseComponent = dataclasses.field(default_factory=empty_component)
@cached_property_no_lock
def _cached_var_name(self) -> str:
"""Get the name of the var.
Returns:
The name of the var.
"""
var_data = self._get_all_var_data()
if var_data is not None:
# flatten imports
imported_names = {j.alias or j.name for i in var_data.imports for j in i[1]}
else:
imported_names = set()
return str(render_dict_to_var(self._var_value.render(), imported_names))
@cached_property_no_lock
def _cached_get_all_var_data(self) -> VarData | None:
"""Get the VarData for the var.
Returns:
The VarData for the var.
"""
return VarData.merge(
VarData(
imports={
"@emotion/react": [
ImportVar(tag="jsx"),
],
}
),
VarData(
imports=self._var_value._get_all_imports(),
),
VarData(
imports={
"react": [
ImportVar(tag="Fragment"),
],
}
),
)
def __hash__(self) -> int:
"""Get the hash of the var.
Returns:
The hash of the var.
"""
return hash((self.__class__.__name__, self._js_expr))
@classmethod
def create(
cls,
value: Component,
_var_data: VarData | None = None,
):
"""Create a var from a value.
Args:
value: The value of the var.
_var_data: Additional hooks and imports associated with the Var.
Returns:
The var.
"""
return LiteralComponentVar(
_js_expr="",
_var_type=type(value),
_var_data=_var_data,
_var_value=value,
)

View File

@ -5,11 +5,17 @@ from __future__ import annotations
from pathlib import Path
from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple
from reflex.components.component import Component, ComponentNamespace, MemoizationLeaf
from reflex.components.component import (
Component,
ComponentNamespace,
MemoizationLeaf,
StatefulComponent,
)
from reflex.components.el.elements.forms import Input
from reflex.components.radix.themes.layout.box import Box
from reflex.config import environment
from reflex.constants import Dirs
from reflex.constants.compiler import Imports
from reflex.event import (
CallableEventSpec,
EventChain,
@ -19,9 +25,10 @@ from reflex.event import (
call_script,
parse_args_spec,
)
from reflex.utils import format
from reflex.utils.imports import ImportVar
from reflex.vars import VarData
from reflex.vars.base import CallableVar, LiteralVar, Var
from reflex.vars.base import CallableVar, LiteralVar, Var, get_unique_variable_name
from reflex.vars.sequence import LiteralStringVar
DEFAULT_UPLOAD_ID: str = "default"
@ -179,9 +186,7 @@ class Upload(MemoizationLeaf):
library = "react-dropzone@14.2.10"
tag = "ReactDropzone"
is_default = True
tag = ""
# The list of accepted file types. This should be a dictionary of MIME types as keys and array of file formats as
# values.
@ -201,7 +206,7 @@ class Upload(MemoizationLeaf):
min_size: Var[int]
# Whether to allow multiple files to be uploaded.
multiple: Var[bool] = True # type: ignore
multiple: Var[bool]
# Whether to disable click to upload.
no_click: Var[bool]
@ -232,6 +237,8 @@ class Upload(MemoizationLeaf):
# Mark the Upload component as used in the app.
cls.is_used = True
props.setdefault("multiple", True)
# Apply the default classname
given_class_name = props.pop("class_name", [])
if isinstance(given_class_name, str):
@ -243,17 +250,6 @@ class Upload(MemoizationLeaf):
upload_props = {
key: value for key, value in props.items() if key in supported_props
}
# The file input to use.
upload = Input.create(type="file")
upload.special_props = [Var(_js_expr="{...getInputProps()}", _var_type=None)]
# The dropzone to use.
zone = Box.create(
upload,
*children,
**{k: v for k, v in props.items() if k not in supported_props},
)
zone.special_props = [Var(_js_expr="{...getRootProps()}", _var_type=None)]
# Create the component.
upload_props["id"] = props.get("id", DEFAULT_UPLOAD_ID)
@ -275,9 +271,74 @@ class Upload(MemoizationLeaf):
),
)
upload_props["on_drop"] = on_drop
input_props_unique_name = get_unique_variable_name()
root_props_unique_name = get_unique_variable_name()
event_var, callback_str = StatefulComponent._get_memoized_event_triggers(
Box.create(on_click=upload_props["on_drop"]) # type: ignore
)["on_click"]
upload_props["on_drop"] = event_var
upload_props = {
format.to_camel_case(key): value for key, value in upload_props.items()
}
use_dropzone_arguements = {
"onDrop": event_var,
**upload_props,
}
left_side = f"const {{getRootProps: {root_props_unique_name}, getInputProps: {input_props_unique_name}}} "
right_side = f"useDropzone({str(Var.create(use_dropzone_arguements))})"
var_data = VarData.merge(
VarData(
imports=Imports.EVENTS,
hooks={
"const [addEvents, connectError] = useContext(EventLoopContext);": None
},
),
event_var._get_all_var_data(),
VarData(
hooks={
callback_str: None,
f"{left_side} = {right_side};": None,
},
imports={
"react-dropzone": "useDropzone",
**Imports.EVENTS,
},
),
)
# The file input to use.
upload = Input.create(type="file")
upload.special_props = [
Var(
_js_expr=f"{{...{input_props_unique_name}()}}",
_var_type=None,
_var_data=var_data,
)
]
# The dropzone to use.
zone = Box.create(
upload,
*children,
**{k: v for k, v in props.items() if k not in supported_props},
)
zone.special_props = [
Var(
_js_expr=f"{{...{root_props_unique_name}()}}",
_var_type=None,
_var_data=var_data,
)
]
return super().create(
zone,
**upload_props,
)
@classmethod
@ -295,11 +356,6 @@ class Upload(MemoizationLeaf):
return (arg_value[0], placeholder)
return arg_value
def _render(self):
out = super()._render()
out.args = ("getRootProps", "getInputProps")
return out
@staticmethod
def _get_app_wrap_components() -> dict[tuple[int, str], Component]:
return {

View File

@ -6,7 +6,11 @@
from pathlib import Path
from typing import Any, ClassVar, Dict, List, Optional, Union, overload
from reflex.components.component import Component, ComponentNamespace, MemoizationLeaf
from reflex.components.component import (
Component,
ComponentNamespace,
MemoizationLeaf,
)
from reflex.constants import Dirs
from reflex.event import (
CallableEventSpec,

View File

@ -63,6 +63,9 @@ def load_dynamic_serializer():
"""
# Causes a circular import, so we import here.
from reflex.compiler import templates, utils
from reflex.components.base.bare import Bare
component = Bare.create(Var.create(component))
rendered_components = {}
# Include dynamic imports in the shared component.
@ -127,14 +130,15 @@ def load_dynamic_serializer():
module_code_lines[ix] = line.replace(
"export function", "export default function", 1
)
line_stripped = line.strip()
if line_stripped.startswith("{") and line_stripped.endswith("}"):
module_code_lines[ix] = line_stripped[1:-1]
module_code_lines.insert(0, "const React = window.__reflex.react;")
return "\n".join(
[
"//__reflex_evaluate",
"/** @jsx jsx */",
"const { jsx } = window.__reflex['@emotion/react']",
*module_code_lines,
]
)

View File

@ -3,7 +3,7 @@
from __future__ import annotations
import dataclasses
from typing import Any, Dict, List, Optional, Tuple, Union
from typing import Any, Dict, List, Optional, Union
from reflex.event import EventChain
from reflex.utils import format, types
@ -23,9 +23,6 @@ class Tag:
# The inner contents of the tag.
contents: str = ""
# Args to pass to the tag.
args: Optional[Tuple[str, ...]] = None
# Special props that aren't key value pairs.
special_props: List[Var] = dataclasses.field(default_factory=list)

View File

@ -121,7 +121,7 @@ def FormSubmitName(form_component):
on_change=rx.console_log,
),
rx.button("Submit", type_="submit"),
rx.icon_button(FormState.val, icon=rx.icon(tag="plus")),
rx.icon_button(rx.icon(tag="plus")),
),
on_submit=FormState.form_submit,
custom_attrs={"action": "/invalid"},

View File

@ -793,8 +793,8 @@ def test_var_operations(driver, var_operations: AppHarness):
("foreach_list_ix", "1\n2"),
("foreach_list_nested", "1\n1\n2"),
# rx.memo component with state
("memo_comp", "[1,2]10"),
("memo_comp_nested", "[3,4]5"),
("memo_comp", "1210"),
("memo_comp_nested", "345"),
# foreach in a match
("foreach_in_match", "first\nsecond\nthird"),
]

View File

@ -1,205 +0,0 @@
import pytest
import reflex as rx
@pytest.fixture
def upload_root_component():
"""A test upload component function.
Returns:
A test upload component function.
"""
def upload_root_component():
return rx.upload.root(
rx.button("select file"),
rx.text("Drag and drop files here or click to select files"),
border="1px dotted black",
)
return upload_root_component()
@pytest.fixture
def upload_component():
"""A test upload component function.
Returns:
A test upload component function.
"""
def upload_component():
return rx.upload(
rx.button("select file"),
rx.text("Drag and drop files here or click to select files"),
border="1px dotted black",
)
return upload_component()
@pytest.fixture
def upload_component_id_special():
def upload_component():
return rx.upload(
rx.button("select file"),
rx.text("Drag and drop files here or click to select files"),
border="1px dotted black",
id="#spec!`al-_98ID",
)
return upload_component()
@pytest.fixture
def upload_component_with_props():
"""A test upload component with props function.
Returns:
A test upload component with props function.
"""
def upload_component_with_props():
return rx.upload(
rx.button("select file"),
rx.text("Drag and drop files here or click to select files"),
border="1px dotted black",
no_drag=True,
max_files=2,
)
return upload_component_with_props()
def test_upload_root_component_render(upload_root_component):
"""Test that the render function is set correctly.
Args:
upload_root_component: component fixture
"""
upload = upload_root_component.render()
# upload
assert upload["name"] == "ReactDropzone"
assert upload["props"] == [
'id={"default"}',
"multiple={true}",
"onDrop={e => setFilesById(filesById => {\n"
" const updatedFilesById = Object.assign({}, filesById);\n"
' updatedFilesById["default"] = e;\n'
" return updatedFilesById;\n"
" })\n"
" }",
"ref={ref_default}",
]
assert upload["args"] == ("getRootProps", "getInputProps")
# box inside of upload
[box] = upload["children"]
assert box["name"] == "RadixThemesBox"
assert box["props"] == [
'className={"rx-Upload"}',
'css={({ ["border"] : "1px dotted black" })}',
"{...getRootProps()}",
]
# input, button and text inside of box
[input, button, text] = box["children"]
assert input["name"] == "input"
assert input["props"] == ['type={"file"}', "{...getInputProps()}"]
assert button["name"] == "RadixThemesButton"
assert button["children"][0]["contents"] == '{"select file"}'
assert text["name"] == "RadixThemesText"
assert (
text["children"][0]["contents"]
== '{"Drag and drop files here or click to select files"}'
)
def test_upload_component_render(upload_component):
"""Test that the render function is set correctly.
Args:
upload_component: component fixture
"""
upload = upload_component.render()
# upload
assert upload["name"] == "ReactDropzone"
assert upload["props"] == [
'id={"default"}',
"multiple={true}",
"onDrop={e => setFilesById(filesById => {\n"
" const updatedFilesById = Object.assign({}, filesById);\n"
' updatedFilesById["default"] = e;\n'
" return updatedFilesById;\n"
" })\n"
" }",
"ref={ref_default}",
]
assert upload["args"] == ("getRootProps", "getInputProps")
# box inside of upload
[box] = upload["children"]
assert box["name"] == "RadixThemesBox"
assert box["props"] == [
'className={"rx-Upload"}',
'css={({ ["border"] : "1px dotted black", ["padding"] : "5em", ["textAlign"] : "center" })}',
"{...getRootProps()}",
]
# input, button and text inside of box
[input, button, text] = box["children"]
assert input["name"] == "input"
assert input["props"] == ['type={"file"}', "{...getInputProps()}"]
assert button["name"] == "RadixThemesButton"
assert button["children"][0]["contents"] == '{"select file"}'
assert text["name"] == "RadixThemesText"
assert (
text["children"][0]["contents"]
== '{"Drag and drop files here or click to select files"}'
)
def test_upload_component_with_props_render(upload_component_with_props):
"""Test that the render function is set correctly.
Args:
upload_component_with_props: component fixture
"""
upload = upload_component_with_props.render()
assert upload["props"] == [
'id={"default"}',
"maxFiles={2}",
"multiple={true}",
"noDrag={true}",
"onDrop={e => setFilesById(filesById => {\n"
" const updatedFilesById = Object.assign({}, filesById);\n"
' updatedFilesById["default"] = e;\n'
" return updatedFilesById;\n"
" })\n"
" }",
"ref={ref_default}",
]
def test_upload_component_id_with_special_chars(upload_component_id_special):
upload = upload_component_id_special.render()
assert upload["props"] == [
r'id={"#spec!`al-_98ID"}',
"multiple={true}",
"onDrop={e => setFilesById(filesById => {\n"
" const updatedFilesById = Object.assign({}, filesById);\n"
' updatedFilesById["#spec!`al-_98ID"] = e;\n'
" return updatedFilesById;\n"
" })\n"
" }",
"ref={ref__spec_al__98ID}",
]

View File

@ -642,21 +642,18 @@ def test_component_create_unallowed_types(children, test_component):
"name": "Fragment",
"props": [],
"contents": "",
"args": None,
"special_props": [],
"children": [
{
"name": "RadixThemesText",
"props": ['as={"p"}'],
"contents": "",
"args": None,
"special_props": [],
"children": [
{
"name": "",
"props": [],
"contents": '{"first_text"}',
"args": None,
"special_props": [],
"children": [],
"autofocus": False,
@ -671,15 +668,12 @@ def test_component_create_unallowed_types(children, test_component):
(
(rx.text("first_text"), rx.text("second_text")),
{
"args": None,
"autofocus": False,
"children": [
{
"args": None,
"autofocus": False,
"children": [
{
"args": None,
"autofocus": False,
"children": [],
"contents": '{"first_text"}',
@ -694,11 +688,9 @@ def test_component_create_unallowed_types(children, test_component):
"special_props": [],
},
{
"args": None,
"autofocus": False,
"children": [
{
"args": None,
"autofocus": False,
"children": [],
"contents": '{"second_text"}',
@ -722,15 +714,12 @@ def test_component_create_unallowed_types(children, test_component):
(
(rx.text("first_text"), rx.box((rx.text("second_text"),))),
{
"args": None,
"autofocus": False,
"children": [
{
"args": None,
"autofocus": False,
"children": [
{
"args": None,
"autofocus": False,
"children": [],
"contents": '{"first_text"}',
@ -745,19 +734,15 @@ def test_component_create_unallowed_types(children, test_component):
"special_props": [],
},
{
"args": None,
"autofocus": False,
"children": [
{
"args": None,
"autofocus": False,
"children": [
{
"args": None,
"autofocus": False,
"children": [
{
"args": None,
"autofocus": False,
"children": [],
"contents": '{"second_text"}',
@ -1117,10 +1102,10 @@ def test_component_with_only_valid_children(fixture, request):
@pytest.mark.parametrize(
"component,rendered",
[
(rx.text("hi"), '<RadixThemesText as={"p"}>\n {"hi"}\n</RadixThemesText>'),
(rx.text("hi"), '<RadixThemesText as={"p"}>\n\n{"hi"}\n</RadixThemesText>'),
(
rx.box(rx.heading("test", size="3")),
'<RadixThemesBox>\n <RadixThemesHeading size={"3"}>\n {"test"}\n</RadixThemesHeading>\n</RadixThemesBox>',
'<RadixThemesBox>\n\n<RadixThemesHeading size={"3"}>\n\n{"test"}\n</RadixThemesHeading>\n</RadixThemesBox>',
),
],
)