diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index f743b7cbd..cbc34fff9 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -5,7 +5,7 @@ on: types: - closed paths-ignore: - - '**/*.md' + - "**/*.md" permissions: contents: read @@ -15,21 +15,21 @@ defaults: shell: bash env: - PYTHONIOENCODING: 'utf8' + PYTHONIOENCODING: "utf8" TELEMETRY_ENABLED: false - NODE_OPTIONS: '--max_old_space_size=8192' + NODE_OPTIONS: "--max_old_space_size=8192" PR_TITLE: ${{ github.event.pull_request.title }} jobs: reflex-web: -# if: github.event.pull_request.merged == true + # if: github.event.pull_request.merged == true strategy: fail-fast: false matrix: # Show OS combos first in GUI os: [ubuntu-latest] - python-version: ['3.11.4'] - node-version: ['18.x'] + python-version: ["3.12.8"] + node-version: ["18.x"] runs-on: ${{ matrix.os }} steps: @@ -81,24 +81,24 @@ jobs: matrix: # Show OS combos first in GUI os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ['3.9.18', '3.10.13', '3.11.5', '3.12.0'] + python-version: ["3.9.21", "3.10.16", "3.11.11", "3.12.8"] exclude: - os: windows-latest - python-version: '3.10.13' + python-version: "3.10.16" - os: windows-latest - python-version: '3.9.18' + python-version: "3.9.21" # keep only one python version for MacOS - os: macos-latest - python-version: '3.9.18' + python-version: "3.9.21" - os: macos-latest - python-version: '3.10.13' + python-version: "3.10.16" - os: macos-latest - python-version: '3.12.0' + python-version: "3.11.11" include: - os: windows-latest - python-version: '3.10.11' + python-version: "3.10.11" - os: windows-latest - python-version: '3.9.13' + python-version: "3.9.13" runs-on: ${{ matrix.os }} steps: @@ -123,7 +123,7 @@ jobs: --event-type "${{ github.event_name }}" --pr-id "${{ github.event.pull_request.id }}" reflex-dist-size: # This job is used to calculate the size of the Reflex distribution (wheel file) - if: github.event.pull_request.merged == true + if: github.event.pull_request.merged == true timeout-minutes: 30 strategy: # Prioritize getting more information out of the workflow (even if something fails) @@ -133,7 +133,7 @@ jobs: - uses: actions/checkout@v4 - uses: ./.github/actions/setup_build_env with: - python-version: 3.11.5 + python-version: 3.12.8 run-poetry-install: true create-venv-at-path: .venv - name: Build reflex @@ -143,12 +143,12 @@ jobs: # Only run if the database creds are available in this context. run: poetry run python benchmarks/benchmark_package_size.py --os ubuntu-latest - --python-version 3.11.5 --commit-sha "${{ github.sha }}" --pr-id "${{ github.event.pull_request.id }}" + --python-version 3.12.8 --commit-sha "${{ github.sha }}" --pr-id "${{ github.event.pull_request.id }}" --branch-name "${{ github.head_ref || github.ref_name }}" --path ./dist reflex-venv-size: # This job calculates the total size of Reflex and its dependencies - if: github.event.pull_request.merged == true + if: github.event.pull_request.merged == true timeout-minutes: 30 strategy: # Prioritize getting more information out of the workflow (even if something fails) @@ -156,7 +156,7 @@ jobs: matrix: # Show OS combos first in GUI os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ['3.11.5'] + python-version: ["3.12.8"] runs-on: ${{ matrix.os }} steps: @@ -186,6 +186,6 @@ jobs: run: poetry run python benchmarks/benchmark_package_size.py --os "${{ matrix.os }}" --python-version "${{ matrix.python-version }}" --commit-sha "${{ github.sha }}" - --pr-id "${{ github.event.pull_request.id }}" + --pr-id "${{ github.event.pull_request.id }}" --branch-name "${{ github.head_ref || github.ref_name }}" - --path ./.venv \ No newline at end of file + --path ./.venv diff --git a/.github/workflows/check_generated_pyi.yml b/.github/workflows/check_generated_pyi.yml index d9a0e8e71..760707d15 100644 --- a/.github/workflows/check_generated_pyi.yml +++ b/.github/workflows/check_generated_pyi.yml @@ -6,16 +6,16 @@ concurrency: on: push: - branches: ['main'] + branches: ["main"] # We don't just trigger on make_pyi.py and the components dir, because # there are other things that can change the generator output # e.g. black version, reflex.Component, reflex.Var. paths-ignore: - - '**/*.md' + - "**/*.md" pull_request: - branches: ['main'] + branches: ["main"] paths-ignore: - - '**/*.md' + - "**/*.md" jobs: check-generated-pyi-components: @@ -25,7 +25,7 @@ jobs: - uses: actions/checkout@v4 - uses: ./.github/actions/setup_build_env with: - python-version: '3.11.5' + python-version: "3.12.8" run-poetry-install: true create-venv-at-path: .venv - run: | diff --git a/.github/workflows/check_node_latest.yml b/.github/workflows/check_node_latest.yml index 1cf9f6fdf..1957f64f8 100644 --- a/.github/workflows/check_node_latest.yml +++ b/.github/workflows/check_node_latest.yml @@ -1,43 +1,40 @@ name: integration-node-latest on: - push: - branches: - - main - pull_request: - branches: - - main + push: + branches: + - main + pull_request: + branches: + - main env: - TELEMETRY_ENABLED: false - REFLEX_USE_SYSTEM_NODE: true + TELEMETRY_ENABLED: false + REFLEX_USE_SYSTEM_NODE: true jobs: - check_latest_node: - runs-on: ubuntu-22.04 - strategy: - matrix: - python-version: ['3.12'] - split_index: [1, 2] - node-version: ['node'] - fail-fast: false - - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/setup_build_env - with: - python-version: ${{ matrix.python-version }} - run-poetry-install: true - create-venv-at-path: .venv - - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - - run: | - poetry run uv pip install pyvirtualdisplay pillow pytest-split - poetry run playwright install --with-deps - - run: | - poetry run pytest tests/test_node_version.py - poetry run pytest tests/integration --splits 2 --group ${{matrix.split_index}} - - + check_latest_node: + runs-on: ubuntu-22.04 + strategy: + matrix: + python-version: ["3.12.8"] + split_index: [1, 2] + node-version: ["node"] + fail-fast: false + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup_build_env + with: + python-version: ${{ matrix.python-version }} + run-poetry-install: true + create-venv-at-path: .venv + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - run: | + poetry run uv pip install pyvirtualdisplay pillow pytest-split + poetry run playwright install --with-deps + - run: | + poetry run pytest tests/test_node_version.py + poetry run pytest tests/integration --splits 2 --group ${{matrix.split_index}} diff --git a/.github/workflows/check_outdated_dependencies.yml b/.github/workflows/check_outdated_dependencies.yml index a7465defb..30e048912 100644 --- a/.github/workflows/check_outdated_dependencies.yml +++ b/.github/workflows/check_outdated_dependencies.yml @@ -1,88 +1,86 @@ name: check-outdated-dependencies on: - push: # This will trigger the action when a pull request is opened or updated. + push: # This will trigger the action when a pull request is opened or updated. branches: - - 'release/**' # This will trigger the action when any branch starting with "release/" is created. - workflow_dispatch: # Allow manual triggering if needed. + - "release/**" # This will trigger the action when any branch starting with "release/" is created. + workflow_dispatch: # Allow manual triggering if needed. jobs: backend: runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v3 + - name: Checkout code + uses: actions/checkout@v3 - - uses: ./.github/actions/setup_build_env - with: - python-version: '3.9' - run-poetry-install: true - create-venv-at-path: .venv + - uses: ./.github/actions/setup_build_env + with: + python-version: "3.9.21" + run-poetry-install: true + create-venv-at-path: .venv - - name: Check outdated backend dependencies - run: | - outdated=$(poetry show -oT) - echo "Outdated:" - echo "$outdated" + - name: Check outdated backend dependencies + run: | + outdated=$(poetry show -oT) + echo "Outdated:" + echo "$outdated" - filtered_outdated=$(echo "$outdated" | grep -vE 'pyright|ruff' || true) - - if [ ! -z "$filtered_outdated" ]; then - echo "Outdated dependencies found:" - echo "$filtered_outdated" - exit 1 - else - echo "All dependencies are up to date. (pyright and ruff are ignored)" - fi + filtered_outdated=$(echo "$outdated" | grep -vE 'pyright|ruff' || true) + if [ ! -z "$filtered_outdated" ]; then + echo "Outdated dependencies found:" + echo "$filtered_outdated" + exit 1 + else + echo "All dependencies are up to date. (pyright and ruff are ignored)" + fi frontend: runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v4 - - uses: ./.github/actions/setup_build_env - with: - python-version: '3.10.11' - run-poetry-install: true - create-venv-at-path: .venv - - name: Clone Reflex Website Repo - uses: actions/checkout@v4 - with: - repository: reflex-dev/reflex-web - ref: main - path: reflex-web - - name: Install Requirements for reflex-web - working-directory: ./reflex-web - run: poetry run uv pip install -r requirements.txt - - name: Install additional dependencies for DB access - run: poetry run uv pip install psycopg - - name: Init Website for reflex-web - working-directory: ./reflex-web - run: poetry run reflex init - - name: Run Website and Check for errors - run: | - poetry run bash scripts/integration.sh ./reflex-web dev - - name: Check outdated frontend dependencies - working-directory: ./reflex-web/.web - run: | - raw_outdated=$(/home/runner/.local/share/reflex/bun/bin/bun outdated) - outdated=$(echo "$raw_outdated" | grep -vE '\|\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\|' || true) - echo "Outdated:" - echo "$outdated" + - name: Checkout code + uses: actions/checkout@v4 + - uses: ./.github/actions/setup_build_env + with: + python-version: "3.10.16" + run-poetry-install: true + create-venv-at-path: .venv + - name: Clone Reflex Website Repo + uses: actions/checkout@v4 + with: + repository: reflex-dev/reflex-web + ref: main + path: reflex-web + - name: Install Requirements for reflex-web + working-directory: ./reflex-web + run: poetry run uv pip install -r requirements.txt + - name: Install additional dependencies for DB access + run: poetry run uv pip install psycopg + - name: Init Website for reflex-web + working-directory: ./reflex-web + run: poetry run reflex init + - name: Run Website and Check for errors + run: | + poetry run bash scripts/integration.sh ./reflex-web dev + - name: Check outdated frontend dependencies + working-directory: ./reflex-web/.web + run: | + raw_outdated=$(/home/runner/.local/share/reflex/bun/bin/bun outdated) + outdated=$(echo "$raw_outdated" | grep -vE '\|\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\|' || true) + echo "Outdated:" + echo "$outdated" - # Ignore 3rd party dependencies that are not updated. - filtered_outdated=$(echo "$outdated" | grep -vE 'Package|@chakra-ui|lucide-react|@splinetool/runtime|ag-grid-react|framer-motion|react-markdown|remark-math|remark-gfm|rehype-katex|rehype-raw|remark-unwrap-images' || true) - no_extra=$(echo "$filtered_outdated" | grep -vE '\|\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-' || true) + # Ignore 3rd party dependencies that are not updated. + filtered_outdated=$(echo "$outdated" | grep -vE 'Package|@chakra-ui|lucide-react|@splinetool/runtime|ag-grid-react|framer-motion|react-markdown|remark-math|remark-gfm|rehype-katex|rehype-raw|remark-unwrap-images' || true) + no_extra=$(echo "$filtered_outdated" | grep -vE '\|\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-' || true) - if [ ! -z "$no_extra" ]; then - echo "Outdated dependencies found:" - echo "$filtered_outdated" - exit 1 - else - echo "All dependencies are up to date. (3rd party packages are ignored)" - fi - + if [ ! -z "$no_extra" ]; then + echo "Outdated dependencies found:" + echo "$filtered_outdated" + exit 1 + else + echo "All dependencies are up to date. (3rd party packages are ignored)" + fi diff --git a/.github/workflows/integration_app_harness.yml b/.github/workflows/integration_app_harness.yml index e6ea79377..6148ecd1a 100644 --- a/.github/workflows/integration_app_harness.yml +++ b/.github/workflows/integration_app_harness.yml @@ -22,8 +22,8 @@ jobs: timeout-minutes: 30 strategy: matrix: - state_manager: ['redis', 'memory'] - python-version: ['3.11.5', '3.12.0', '3.13.0'] + state_manager: ["redis", "memory"] + python-version: ["3.11.11", "3.12.8", "3.13.1"] split_index: [1, 2] fail-fast: false runs-on: ubuntu-22.04 @@ -53,7 +53,7 @@ jobs: SCREENSHOT_DIR: /tmp/screenshots/${{ matrix.state_manager }}/${{ matrix.python-version }}/${{ matrix.split_index }} REDIS_URL: ${{ matrix.state_manager == 'redis' && 'redis://localhost:6379' || '' }} run: | - poetry run playwright install --with-deps + poetry run playwright install chromium poetry run pytest tests/integration --splits 2 --group ${{matrix.split_index}} - uses: actions/upload-artifact@v4 name: Upload failed test screenshots diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index 6c79c27c9..7e3fecb89 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -2,13 +2,13 @@ name: integration-tests on: push: - branches: ['main'] + branches: ["main"] paths-ignore: - - '**/*.md' + - "**/*.md" pull_request: - branches: ['main'] + branches: ["main"] paths-ignore: - - '**/*.md' + - "**/*.md" concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.id }} @@ -27,9 +27,9 @@ env: # TODO: can we fix windows encoding natively within reflex? Bug above can hit real users too (less common, but possible) # - Catch encoding errors when printing logs # - Best effort print lines that contain illegal chars (map to some default char, etc.) - PYTHONIOENCODING: 'utf8' + PYTHONIOENCODING: "utf8" TELEMETRY_ENABLED: false - NODE_OPTIONS: '--max_old_space_size=8192' + NODE_OPTIONS: "--max_old_space_size=8192" PR_TITLE: ${{ github.event.pull_request.title }} jobs: @@ -43,17 +43,22 @@ jobs: matrix: # Show OS combos first in GUI os: [ubuntu-latest, windows-latest] - python-version: ['3.9.18', '3.10.13', '3.11.5', '3.12.0', '3.13.0'] + python-version: ["3.9.21", "3.10.16", "3.11.11", "3.12.8", "3.13.1"] + # Windows is a bit behind on Python version availability in Github exclude: - os: windows-latest - python-version: '3.10.13' + python-version: "3.11.11" - os: windows-latest - python-version: '3.9.18' + python-version: "3.10.16" + - os: windows-latest + python-version: "3.9.21" include: - os: windows-latest - python-version: '3.10.11' + python-version: "3.11.9" - os: windows-latest - python-version: '3.9.13' + python-version: "3.10.11" + - os: windows-latest + python-version: "3.9.13" runs-on: ${{ matrix.os }} steps: @@ -117,18 +122,16 @@ jobs: --branch-name "${{ github.head_ref || github.ref_name }}" --pr-id "${{ github.event.pull_request.id }}" --app-name "counter" - - reflex-web: strategy: fail-fast: false matrix: # Show OS combos first in GUI os: [ubuntu-latest] - python-version: ['3.10.11', '3.11.4'] + python-version: ["3.11.11", "3.12.8"] env: - REFLEX_WEB_WINDOWS_OVERRIDE: '1' + REFLEX_WEB_WINDOWS_OVERRIDE: "1" runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -173,7 +176,7 @@ jobs: - uses: actions/checkout@v4 - uses: ./.github/actions/setup_build_env with: - python-version: '3.11.4' + python-version: "3.11.11" run-poetry-install: true create-venv-at-path: .venv - name: Create app directory @@ -192,14 +195,14 @@ jobs: # Check that npm is home npm -v poetry run bash scripts/integration.sh ./rx-shout-from-template prod - reflex-web-macos: if: github.event_name == 'push' && github.ref == 'refs/heads/main' strategy: fail-fast: false matrix: - python-version: ['3.11.5', '3.12.0'] + # Note: py311 version chosen due to available arm64 darwin builds. + python-version: ["3.11.9", "3.12.8"] runs-on: macos-latest steps: - uses: actions/checkout@v4 @@ -233,4 +236,3 @@ jobs: --python-version "${{ matrix.python-version }}" --commit-sha "${{ github.sha }}" --pr-id "${{ github.event.pull_request.id }}" --branch-name "${{ github.head_ref || github.ref_name }}" --app-name "reflex-web" --path ./reflex-web/.web - \ No newline at end of file diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 9e6e42a38..4c71e3035 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -6,12 +6,12 @@ concurrency: on: pull_request: - branches: ['main'] + branches: ["main"] push: # Note even though this job is called "pre-commit" and runs "pre-commit", this job will run # also POST-commit on main also! In case there are mishandled merge conflicts / bad auto-resolves # when merging into main branch. - branches: ['main'] + branches: ["main"] jobs: pre-commit: @@ -23,7 +23,7 @@ jobs: with: # running vs. one version of Python is OK # i.e. ruff, black, etc. - python-version: 3.11.5 + python-version: 3.12.8 run-poetry-install: true create-venv-at-path: .venv # TODO pre-commit related stuff can be cached too (not a bottleneck yet) diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 25f5723f3..e0a3723ac 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -6,13 +6,13 @@ concurrency: on: push: - branches: ['main'] + branches: ["main"] paths-ignore: - - '**/*.md' + - "**/*.md" pull_request: - branches: ['main'] + branches: ["main"] paths-ignore: - - '**/*.md' + - "**/*.md" permissions: contents: read @@ -28,18 +28,22 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] - python-version: ['3.9.18', '3.10.13', '3.11.5', '3.12.0', '3.13.0'] + python-version: ["3.9.21", "3.10.16", "3.11.11", "3.12.8", "3.13.1"] # Windows is a bit behind on Python version availability in Github exclude: - os: windows-latest - python-version: '3.10.13' + python-version: "3.11.11" - os: windows-latest - python-version: '3.9.18' + python-version: "3.10.16" + - os: windows-latest + python-version: "3.9.21" include: - os: windows-latest - python-version: '3.10.11' + python-version: "3.11.9" - os: windows-latest - python-version: '3.9.13' + python-version: "3.10.11" + - os: windows-latest + python-version: "3.9.13" runs-on: ${{ matrix.os }} # Service containers to run with `runner-job` @@ -88,8 +92,8 @@ jobs: strategy: fail-fast: false matrix: - # Note: py39, py310 versions chosen due to available arm64 darwin builds. - python-version: ['3.9.13', '3.10.11', '3.11.5', '3.12.0', '3.13.0'] + # Note: py39, py310, py311 versions chosen due to available arm64 darwin builds. + python-version: ["3.9.13", "3.10.11", "3.11.9", "3.12.8", "3.13.1"] runs-on: macos-latest steps: - uses: actions/checkout@v4 @@ -106,4 +110,4 @@ jobs: run: | export PYTHONUNBUFFERED=1 poetry run uv pip install "pydantic~=1.10" - poetry run pytest tests/units --cov --no-cov-on-fail --cov-report= \ No newline at end of file + poetry run pytest tests/units --cov --no-cov-on-fail --cov-report= diff --git a/pyproject.toml b/pyproject.toml index e50ef88bb..3e76ec5b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,6 @@ repository = "https://github.com/reflex-dev/reflex" documentation = "https://reflex.dev/docs/getting-started/introduction" keywords = ["web", "framework"] classifiers = ["Development Status :: 4 - Beta"] -packages = [{ include = "reflex" }] [tool.poetry.dependencies] python = "^3.9" diff --git a/reflex/.templates/jinja/web/pages/_app.js.jinja2 b/reflex/.templates/jinja/web/pages/_app.js.jinja2 index 21cfd921a..40e31dee6 100644 --- a/reflex/.templates/jinja/web/pages/_app.js.jinja2 +++ b/reflex/.templates/jinja/web/pages/_app.js.jinja2 @@ -1,4 +1,5 @@ {% extends "web/pages/base_page.js.jinja2" %} +{% from "web/pages/macros.js.jinja2" import renderHooks %} {% block early_imports %} import '$/styles/styles.css' @@ -18,10 +19,7 @@ import * as {{library_alias}} from "{{library_path}}"; {% block export %} function AppWrap({children}) { - - {% for hook in hooks %} - {{ hook }} - {% endfor %} + {{ renderHooks(hooks) }} return ( {{utils.render(render, indent_width=0)}} diff --git a/reflex/.templates/jinja/web/pages/custom_component.js.jinja2 b/reflex/.templates/jinja/web/pages/custom_component.js.jinja2 index 222524d2d..e729d7273 100644 --- a/reflex/.templates/jinja/web/pages/custom_component.js.jinja2 +++ b/reflex/.templates/jinja/web/pages/custom_component.js.jinja2 @@ -1,5 +1,5 @@ {% extends "web/pages/base_page.js.jinja2" %} - +{% from "web/pages/macros.js.jinja2" import renderHooks %} {% block export %} {% for component in components %} @@ -8,9 +8,8 @@ {% endfor %} export const {{component.name}} = memo(({ {{-component.props|join(", ")-}} }) => { - {% for hook in component.hooks %} - {{ hook }} - {% endfor %} + {{ renderHooks(component.hooks) }} + return( {{utils.render(component.render)}} ) diff --git a/reflex/.templates/jinja/web/pages/index.js.jinja2 b/reflex/.templates/jinja/web/pages/index.js.jinja2 index efb086ef5..5551ad5fc 100644 --- a/reflex/.templates/jinja/web/pages/index.js.jinja2 +++ b/reflex/.templates/jinja/web/pages/index.js.jinja2 @@ -1,4 +1,5 @@ {% extends "web/pages/base_page.js.jinja2" %} +{% from "web/pages/macros.js.jinja2" import renderHooks %} {% block declaration %} {% for custom_code in custom_codes %} @@ -8,9 +9,7 @@ {% block export %} export default function Component() { - {% for hook in hooks %} - {{ hook }} - {% endfor %} + {{ renderHooks(hooks)}} return ( {{utils.render(render, indent_width=0)}} diff --git a/reflex/.templates/jinja/web/pages/macros.js.jinja2 b/reflex/.templates/jinja/web/pages/macros.js.jinja2 new file mode 100644 index 000000000..68810d896 --- /dev/null +++ b/reflex/.templates/jinja/web/pages/macros.js.jinja2 @@ -0,0 +1,38 @@ +{% macro renderHooks(hooks) %} + {% set sorted_hooks = sort_hooks(hooks) %} + + {# Render the grouped hooks #} + {% for hook, _ in sorted_hooks[const.hook_position.INTERNAL] %} + {{ hook }} + {% endfor %} + + {% for hook, _ in sorted_hooks[const.hook_position.PRE_TRIGGER] %} + {{ hook }} + {% endfor %} + + {% for hook, _ in sorted_hooks[const.hook_position.POST_TRIGGER] %} + {{ hook }} + {% endfor %} +{% endmacro %} + +{% macro renderHooksWithMemo(hooks, memo)%} + {% set sorted_hooks = sort_hooks(hooks) %} + + {# Render the grouped hooks #} + {% for hook, _ in sorted_hooks[const.hook_position.INTERNAL] %} + {{ hook }} + {% endfor %} + + {% for hook, _ in sorted_hooks[const.hook_position.PRE_TRIGGER] %} + {{ hook }} + {% endfor %} + + {% for hook in memo %} + {{ hook }} + {% endfor %} + + {% for hook, _ in sorted_hooks[const.hook_position.POST_TRIGGER] %} + {{ hook }} + {% endfor %} + +{% endmacro %} \ No newline at end of file diff --git a/reflex/.templates/jinja/web/pages/stateful_component.js.jinja2 b/reflex/.templates/jinja/web/pages/stateful_component.js.jinja2 index b04a78781..208a5755f 100644 --- a/reflex/.templates/jinja/web/pages/stateful_component.js.jinja2 +++ b/reflex/.templates/jinja/web/pages/stateful_component.js.jinja2 @@ -1,22 +1,10 @@ {% import 'web/pages/utils.js.jinja2' as utils %} +{% from 'web/pages/macros.js.jinja2' import renderHooksWithMemo %} +{% set all_hooks = component._get_all_hooks() %} export function {{tag_name}} () { - {% for hook in component._get_all_hooks_internal() %} - {{ hook }} - {% endfor %} - - {% for hook, data in component._get_all_hooks().items() if not data.position or data.position == const.hook_position.PRE_TRIGGER %} - {{ hook }} - {% endfor %} - - {% for hook in memo_trigger_hooks %} - {{ hook }} - {% endfor %} - - {% for hook, data in component._get_all_hooks().items() if data.position and data.position == const.hook_position.POST_TRIGGER %} - {{ hook }} - {% endfor %} - + {{ renderHooksWithMemo(all_hooks, memo_trigger_hooks) }} + return ( {{utils.render(component.render(), indent_width=0)}} ) diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 9f81f319d..218dd0c55 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -75,7 +75,7 @@ def _compile_app(app_root: Component) -> str: return templates.APP_ROOT.render( imports=utils.compile_imports(app_root._get_all_imports()), custom_codes=app_root._get_all_custom_code(), - hooks={**app_root._get_all_hooks_internal(), **app_root._get_all_hooks()}, + hooks=app_root._get_all_hooks(), window_libraries=window_libraries, render=app_root.render(), ) @@ -149,7 +149,7 @@ def _compile_page( imports=imports, dynamic_imports=component._get_all_dynamic_imports(), custom_codes=component._get_all_custom_code(), - hooks={**component._get_all_hooks_internal(), **component._get_all_hooks()}, + hooks=component._get_all_hooks(), render=component.render(), **kwargs, ) diff --git a/reflex/compiler/templates.py b/reflex/compiler/templates.py index 631aa4ee2..117b655a9 100644 --- a/reflex/compiler/templates.py +++ b/reflex/compiler/templates.py @@ -1,9 +1,46 @@ """Templates to use in the reflex compiler.""" +from __future__ import annotations + from jinja2 import Environment, FileSystemLoader, Template from reflex import constants +from reflex.constants import Hooks from reflex.utils.format import format_state_name, json_dumps +from reflex.vars.base import VarData + + +def _sort_hooks(hooks: dict[str, VarData | None]): + """Sort the hooks by their position. + + Args: + hooks: The hooks to sort. + + Returns: + The sorted hooks. + """ + sorted_hooks = { + Hooks.HookPosition.INTERNAL: [], + Hooks.HookPosition.PRE_TRIGGER: [], + Hooks.HookPosition.POST_TRIGGER: [], + } + + for hook, data in hooks.items(): + if data and data.position and data.position == Hooks.HookPosition.INTERNAL: + sorted_hooks[Hooks.HookPosition.INTERNAL].append((hook, data)) + elif not data or ( + not data.position + or data.position == constants.Hooks.HookPosition.PRE_TRIGGER + ): + sorted_hooks[Hooks.HookPosition.PRE_TRIGGER].append((hook, data)) + elif ( + data + and data.position + and data.position == constants.Hooks.HookPosition.POST_TRIGGER + ): + sorted_hooks[Hooks.HookPosition.POST_TRIGGER].append((hook, data)) + + return sorted_hooks class ReflexJinjaEnvironment(Environment): @@ -47,6 +84,7 @@ class ReflexJinjaEnvironment(Environment): "frontend_exception_state": constants.CompileVars.FRONTEND_EXCEPTION_STATE_FULL, "hook_position": constants.Hooks.HookPosition, } + self.globals["sort_hooks"] = _sort_hooks def get_template(name: str) -> Template: @@ -103,6 +141,9 @@ STYLE = get_template("web/styles/styles.css.jinja2") # Code that generate the package json file PACKAGE_JSON = get_template("web/package.json.jinja2") +# Template containing some macros used in the web pages. +MACROS = get_template("web/pages/macros.js.jinja2") + # Code that generate the pyproject.toml file for custom components. CUSTOM_COMPONENTS_PYPROJECT_TOML = get_template( "custom_components/pyproject.toml.jinja2" diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index 1d698431c..c0ba28f4b 100644 --- a/reflex/compiler/utils.py +++ b/reflex/compiler/utils.py @@ -290,7 +290,7 @@ def compile_custom_component( "name": component.tag, "props": props, "render": render.render(), - "hooks": {**render._get_all_hooks_internal(), **render._get_all_hooks()}, + "hooks": render._get_all_hooks(), "custom_code": render._get_all_custom_code(), }, imports, diff --git a/reflex/components/base/bare.py b/reflex/components/base/bare.py index e1b5d9237..e576fac85 100644 --- a/reflex/components/base/bare.py +++ b/reflex/components/base/bare.py @@ -9,6 +9,7 @@ from reflex.components.tags import Tag from reflex.components.tags.tagless import Tagless from reflex.utils.imports import ParsedImportDict from reflex.vars import BooleanVar, ObjectVar, Var +from reflex.vars.base import VarData class Bare(Component): @@ -32,7 +33,7 @@ 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]: + def _get_all_hooks_internal(self) -> dict[str, VarData | None]: """Include the hooks for the component. Returns: @@ -43,7 +44,7 @@ class Bare(Component): hooks |= self.contents._var_value._get_all_hooks_internal() return hooks - def _get_all_hooks(self) -> dict[str, None]: + def _get_all_hooks(self) -> dict[str, VarData | None]: """Include the hooks for the component. Returns: @@ -107,11 +108,14 @@ class Bare(Component): return Tagless(contents=f"{{{self.contents!s}}}") return Tagless(contents=str(self.contents)) - def _get_vars(self, include_children: bool = False) -> Iterator[Var]: + def _get_vars( + self, include_children: bool = False, ignore_ids: set[int] | None = None + ) -> Iterator[Var]: """Walk all Vars used in this component. Args: include_children: Whether to include Vars from children. + ignore_ids: The ids to ignore. Yields: The contents if it is a Var, otherwise nothing. diff --git a/reflex/components/component.py b/reflex/components/component.py index c1d4cbc80..8649b593d 100644 --- a/reflex/components/component.py +++ b/reflex/components/component.py @@ -102,7 +102,7 @@ class BaseComponent(Base, ABC): """ @abstractmethod - def _get_all_hooks_internal(self) -> dict[str, None]: + def _get_all_hooks_internal(self) -> dict[str, VarData | None]: """Get the reflex internal hooks for the component and its children. Returns: @@ -110,7 +110,7 @@ class BaseComponent(Base, ABC): """ @abstractmethod - def _get_all_hooks(self) -> dict[str, None]: + def _get_all_hooks(self) -> dict[str, VarData | None]: """Get the React hooks for this component. Returns: @@ -1020,18 +1020,22 @@ class Component(BaseComponent, ABC): event_args.append(spec) yield event_trigger, event_args - def _get_vars(self, include_children: bool = False) -> list[Var]: + def _get_vars( + self, include_children: bool = False, ignore_ids: set[int] | None = None + ) -> Iterator[Var]: """Walk all Vars used in this component. Args: include_children: Whether to include Vars from children. + ignore_ids: The ids to ignore. - Returns: + Yields: Each var referenced by the component (props, styles, event handlers). """ - vars = getattr(self, "__vars", None) + ignore_ids = ignore_ids or set() + vars: List[Var] | None = getattr(self, "__vars", None) if vars is not None: - return vars + yield from vars vars = self.__vars = [] # Get Vars associated with event trigger arguments. for _, event_vars in self._get_vars_from_event_triggers(self.event_triggers): @@ -1075,12 +1079,15 @@ class Component(BaseComponent, ABC): # Get Vars associated with children. if include_children: for child in self.children: - if not isinstance(child, Component): + if not isinstance(child, Component) or id(child) in ignore_ids: continue - child_vars = child._get_vars(include_children=include_children) + ignore_ids.add(id(child)) + child_vars = child._get_vars( + include_children=include_children, ignore_ids=ignore_ids + ) vars.extend(child_vars) - return vars + yield from vars def _event_trigger_values_use_state(self) -> bool: """Check if the values of a component's event trigger use state. @@ -1272,7 +1279,7 @@ class Component(BaseComponent, ABC): """ _imports = {} - if self._get_ref_hook(): + if self._get_ref_hook() is not None: # Handle hooks needed for attaching react refs to DOM nodes. _imports.setdefault("react", set()).add(ImportVar(tag="useRef")) _imports.setdefault(f"$/{Dirs.STATE_PATH}", set()).add( @@ -1388,7 +1395,7 @@ class Component(BaseComponent, ABC): }} }}, []);""" - def _get_ref_hook(self) -> str | None: + def _get_ref_hook(self) -> Var | None: """Generate the ref hook for the component. Returns: @@ -1396,11 +1403,12 @@ class Component(BaseComponent, ABC): """ ref = self.get_ref() if ref is not None: - return ( - f"const {ref} = useRef(null); {Var(_js_expr=ref)._as_ref()!s} = {ref};" + return Var( + f"const {ref} = useRef(null); {Var(_js_expr=ref)._as_ref()!s} = {ref};", + _var_data=VarData(position=Hooks.HookPosition.INTERNAL), ) - def _get_vars_hooks(self) -> dict[str, None]: + def _get_vars_hooks(self) -> dict[str, VarData | None]: """Get the hooks required by vars referenced in this component. Returns: @@ -1413,27 +1421,38 @@ class Component(BaseComponent, ABC): vars_hooks.update( var_data.hooks if isinstance(var_data.hooks, dict) - else {k: None for k in var_data.hooks} + else { + k: VarData(position=Hooks.HookPosition.INTERNAL) + for k in var_data.hooks + } ) return vars_hooks - def _get_events_hooks(self) -> dict[str, None]: + def _get_events_hooks(self) -> dict[str, VarData | None]: """Get the hooks required by events referenced in this component. Returns: The hooks for the events. """ - return {Hooks.EVENTS: None} if self.event_triggers else {} + return ( + {Hooks.EVENTS: VarData(position=Hooks.HookPosition.INTERNAL)} + if self.event_triggers + else {} + ) - def _get_special_hooks(self) -> dict[str, None]: + def _get_special_hooks(self) -> dict[str, VarData | None]: """Get the hooks required by special actions referenced in this component. Returns: The hooks for special actions. """ - return {Hooks.AUTOFOCUS: None} if self.autofocus else {} + return ( + {Hooks.AUTOFOCUS: VarData(position=Hooks.HookPosition.INTERNAL)} + if self.autofocus + else {} + ) - def _get_hooks_internal(self) -> dict[str, None]: + def _get_hooks_internal(self) -> dict[str, VarData | None]: """Get the React hooks for this component managed by the framework. Downstream components should NOT override this method to avoid breaking @@ -1444,7 +1463,7 @@ class Component(BaseComponent, ABC): """ return { **{ - hook: None + str(hook): VarData(position=Hooks.HookPosition.INTERNAL) for hook in [self._get_ref_hook(), self._get_mount_lifecycle_hook()] if hook is not None }, @@ -1493,7 +1512,7 @@ class Component(BaseComponent, ABC): """ return - def _get_all_hooks_internal(self) -> dict[str, None]: + def _get_all_hooks_internal(self) -> dict[str, VarData | None]: """Get the reflex internal hooks for the component and its children. Returns: @@ -1508,7 +1527,7 @@ class Component(BaseComponent, ABC): return code - def _get_all_hooks(self) -> dict[str, None]: + def _get_all_hooks(self) -> dict[str, VarData | None]: """Get the React hooks for this component and its children. Returns: @@ -1516,6 +1535,9 @@ class Component(BaseComponent, ABC): """ code = {} + # Add the internal hooks for this component. + code.update(self._get_hooks_internal()) + # Add the hook code for this component. hooks = self._get_hooks() if hooks is not None: @@ -1796,19 +1818,25 @@ class CustomComponent(Component): for name, prop in self.props.items() ] - def _get_vars(self, include_children: bool = False) -> list[Var]: + def _get_vars( + self, include_children: bool = False, ignore_ids: set[int] | None = None + ) -> Iterator[Var]: """Walk all Vars used in this component. Args: include_children: Whether to include Vars from children. + ignore_ids: The ids to ignore. - Returns: + Yields: Each var referenced by the component (props, styles, event handlers). """ - return ( - super()._get_vars(include_children=include_children) - + [prop for prop in self.props.values() if isinstance(prop, Var)] - + self.get_component(self)._get_vars(include_children=include_children) + ignore_ids = ignore_ids or set() + yield from super()._get_vars( + include_children=include_children, ignore_ids=ignore_ids + ) + yield from filter(lambda prop: isinstance(prop, Var), self.props.values()) + yield from self.get_component(self)._get_vars( + include_children=include_children, ignore_ids=ignore_ids ) @lru_cache(maxsize=None) # noqa @@ -2211,7 +2239,7 @@ class StatefulComponent(BaseComponent): ) return trigger_memo - def _get_all_hooks_internal(self) -> dict[str, None]: + def _get_all_hooks_internal(self) -> dict[str, VarData | None]: """Get the reflex internal hooks for the component and its children. Returns: @@ -2219,7 +2247,7 @@ class StatefulComponent(BaseComponent): """ return {} - def _get_all_hooks(self) -> dict[str, None]: + def _get_all_hooks(self) -> dict[str, VarData | None]: """Get the React hooks for this component. Returns: @@ -2337,7 +2365,7 @@ class MemoizationLeaf(Component): The memoization leaf """ comp = super().create(*children, **props) - if comp._get_all_hooks() or comp._get_all_hooks_internal(): + if comp._get_all_hooks(): comp._memoization_mode = cls._memoization_mode.copy( update={"disposition": MemoizationDisposition.ALWAYS} ) diff --git a/reflex/components/datadisplay/code.py b/reflex/components/datadisplay/code.py index 2d5dfc625..8a433c18c 100644 --- a/reflex/components/datadisplay/code.py +++ b/reflex/components/datadisplay/code.py @@ -502,8 +502,8 @@ class CodeBlock(Component, MarkdownComponentMap): theme = self.theme - out.add_props(style=theme).remove_props("theme", "code", "language").add_props( - children=self.code, language=_LANGUAGE + out.add_props(style=theme).remove_props("theme", "code").add_props( + children=self.code, ) return out @@ -512,20 +512,25 @@ class CodeBlock(Component, MarkdownComponentMap): return ["can_copy", "copy_button"] @classmethod - def _get_language_registration_hook(cls) -> str: + def _get_language_registration_hook(cls, language_var: Var = _LANGUAGE) -> str: """Get the hook to register the language. + Args: + language_var: The const/literal Var of the language module to import. + For markdown, uses the default placeholder _LANGUAGE. For direct use, + a LiteralStringVar should be passed via the language prop. + Returns: The hook to register the language. """ return f""" - if ({_LANGUAGE!s}) {{ + if ({language_var!s}) {{ (async () => {{ try {{ - const module = await import(`react-syntax-highlighter/dist/cjs/languages/prism/${{{_LANGUAGE!s}}}`); - SyntaxHighlighter.registerLanguage({_LANGUAGE!s}, module.default); + const module = await import(`react-syntax-highlighter/dist/cjs/languages/prism/${{{language_var!s}}}`); + SyntaxHighlighter.registerLanguage({language_var!s}, module.default); }} catch (error) {{ - console.error(`Error importing language module for ${{{_LANGUAGE!s}}}:`, error); + console.error(`Error importing language module for ${{{language_var!s}}}:`, error); }} }})(); }} @@ -547,8 +552,7 @@ class CodeBlock(Component, MarkdownComponentMap): The hooks for the component. """ return [ - f"const {_LANGUAGE!s} = {self.language!s}", - self._get_language_registration_hook(), + self._get_language_registration_hook(language_var=self.language), ] diff --git a/reflex/components/el/elements/forms.py b/reflex/components/el/elements/forms.py index 61ded4fd3..6b2d83c46 100644 --- a/reflex/components/el/elements/forms.py +++ b/reflex/components/el/elements/forms.py @@ -182,9 +182,7 @@ class Form(BaseHTML): props["handle_submit_unique_name"] = "" form = super().create(*children, **props) form.handle_submit_unique_name = md5( - str({**form._get_all_hooks_internal(), **form._get_all_hooks()}).encode( - "utf-8" - ) + str(form._get_all_hooks()).encode("utf-8") ).hexdigest() return form @@ -252,8 +250,12 @@ class Form(BaseHTML): ) return form_refs - def _get_vars(self, include_children: bool = True) -> Iterator[Var]: - yield from super()._get_vars(include_children=include_children) + def _get_vars( + self, include_children: bool = True, ignore_ids: set[int] | None = None + ) -> Iterator[Var]: + yield from super()._get_vars( + include_children=include_children, ignore_ids=ignore_ids + ) yield from self._get_form_refs().values() def _exclude_props(self) -> list[str]: diff --git a/reflex/components/markdown/markdown.py b/reflex/components/markdown/markdown.py index cd82d5903..7c65c0d43 100644 --- a/reflex/components/markdown/markdown.py +++ b/reflex/components/markdown/markdown.py @@ -420,11 +420,12 @@ const {_LANGUAGE!s} = match ? match[1] : ''; def _get_custom_code(self) -> str | None: hooks = {} + from reflex.compiler.templates import MACROS + for _component in self.component_map.values(): comp = _component(_MOCK_ARG) - hooks.update(comp._get_all_hooks_internal()) hooks.update(comp._get_all_hooks()) - formatted_hooks = "\n".join(hooks.keys()) + formatted_hooks = MACROS.module.renderHooks(hooks) # type: ignore return f""" function {self._get_component_map_name()} () {{ {formatted_hooks} diff --git a/reflex/components/radix/themes/typography/link.py b/reflex/components/radix/themes/typography/link.py index 25a0902cc..c93102408 100644 --- a/reflex/components/radix/themes/typography/link.py +++ b/reflex/components/radix/themes/typography/link.py @@ -76,7 +76,7 @@ class Link(RadixThemesComponent, A, MemoizationLeaf, MarkdownComponentMap): Returns: Component: The link component """ - props.setdefault(":hover", {"color": color("accent", 8)}) + props.setdefault("_hover", {"color": color("accent", 8)}) href = props.get("href") is_external = props.pop("is_external", None) diff --git a/reflex/constants/compiler.py b/reflex/constants/compiler.py index 7ca55f4dd..d98c04d76 100644 --- a/reflex/constants/compiler.py +++ b/reflex/constants/compiler.py @@ -135,6 +135,7 @@ class Hooks(SimpleNamespace): class HookPosition(enum.Enum): """The position of the hook in the component.""" + INTERNAL = "internal" PRE_TRIGGER = "pre_trigger" POST_TRIGGER = "post_trigger" diff --git a/reflex/experimental/client_state.py b/reflex/experimental/client_state.py index 1982b3dfe..45dfef237 100644 --- a/reflex/experimental/client_state.py +++ b/reflex/experimental/client_state.py @@ -12,7 +12,7 @@ from reflex.event import EventChain, EventHandler, EventSpec, run_script from reflex.utils.imports import ImportVar from reflex.vars import VarData, get_unique_variable_name from reflex.vars.base import LiteralVar, Var -from reflex.vars.function import FunctionVar +from reflex.vars.function import ArgsFunctionOperationBuilder, FunctionVar NoValue = object() @@ -45,6 +45,7 @@ class ClientStateVar(Var): # Track the names of the getters and setters _setter_name: str = dataclasses.field(default="") _getter_name: str = dataclasses.field(default="") + _id_name: str = dataclasses.field(default="") # Whether to add the var and setter to the global `refs` object for use in any Component. _global_ref: bool = dataclasses.field(default=True) @@ -96,6 +97,7 @@ class ClientStateVar(Var): """ if var_name is None: var_name = get_unique_variable_name() + id_name = "id_" + get_unique_variable_name() if not isinstance(var_name, str): raise ValueError("var_name must be a string.") if default is NoValue: @@ -105,20 +107,24 @@ class ClientStateVar(Var): else: default_var = default setter_name = f"set{var_name.capitalize()}" - hooks = { + hooks: dict[str, VarData | None] = { + f"const {id_name} = useId()": None, f"const [{var_name}, {setter_name}] = useState({default_var!s})": None, } imports = { - "react": [ImportVar(tag="useState")], + "react": [ImportVar(tag="useState"), ImportVar(tag="useId")], } if global_ref: - hooks[f"{_client_state_ref(var_name)} = {var_name}"] = None - hooks[f"{_client_state_ref(setter_name)} = {setter_name}"] = None + hooks[f"{_client_state_ref(var_name)} ??= {{}}"] = None + hooks[f"{_client_state_ref(setter_name)} ??= {{}}"] = None + hooks[f"{_client_state_ref(var_name)}[{id_name}] = {var_name}"] = None + hooks[f"{_client_state_ref(setter_name)}[{id_name}] = {setter_name}"] = None imports.update(_refs_import) return cls( _js_expr="", _setter_name=setter_name, _getter_name=var_name, + _id_name=id_name, _global_ref=global_ref, _var_type=default_var._var_type, _var_data=VarData.merge( @@ -144,10 +150,11 @@ class ClientStateVar(Var): return ( Var( _js_expr=( - _client_state_ref(self._getter_name) + _client_state_ref(self._getter_name) + f"[{self._id_name}]" if self._global_ref else self._getter_name - ) + ), + _var_data=self._var_data, ) .to(self._var_type) ._replace( @@ -170,28 +177,43 @@ class ClientStateVar(Var): Returns: A special EventChain Var which will set the value when triggered. """ - setter = ( - _client_state_ref(self._setter_name) - if self._global_ref - else self._setter_name - ) _var_data = VarData(imports=_refs_import if self._global_ref else {}) + + arg_name = get_unique_variable_name() + setter = ( + ArgsFunctionOperationBuilder.create( + args_names=(arg_name,), + return_expr=Var("Array.prototype.forEach.call") + .to(FunctionVar) + .call( + Var("Object.values") + .to(FunctionVar) + .call(Var(_client_state_ref(self._setter_name))), + ArgsFunctionOperationBuilder.create( + args_names=("setter",), + return_expr=Var("setter").to(FunctionVar).call(Var(arg_name)), + ), + ), + _var_data=_var_data, + ) + if self._global_ref + else Var(self._setter_name, _var_data=_var_data).to(FunctionVar) + ) + if value is not NoValue: # This is a hack to make it work like an EventSpec taking an arg value_var = LiteralVar.create(value) - _var_data = VarData.merge(_var_data, value_var._get_all_var_data()) value_str = str(value_var) - if value_str.startswith("_"): + setter = ArgsFunctionOperationBuilder.create( # remove patterns of ["*"] from the value_str using regex - arg = re.sub(r"\[\".*\"\]", "", value_str) - setter = f"(({arg}) => {setter}({value_str}))" - else: - setter = f"(() => {setter}({value_str}))" - return Var( - _js_expr=setter, - _var_data=_var_data, - ).to(FunctionVar, EventChain) + args_names=(re.sub(r"\[\".*\"\]", "", value_str),) + if value_str.startswith("_") + else (), + return_expr=setter.call(value_var), + ) + + return setter.to(FunctionVar, EventChain) @property def set(self) -> Var: diff --git a/reflex/utils/prerequisites.py b/reflex/utils/prerequisites.py index 797d28701..d838c0eea 100644 --- a/reflex/utils/prerequisites.py +++ b/reflex/utils/prerequisites.py @@ -28,8 +28,8 @@ import typer from alembic.util.exc import CommandError from packaging import version from redis import Redis as RedisSync -from redis import exceptions from redis.asyncio import Redis +from redis.exceptions import RedisError from reflex import constants, model from reflex.compiler import templates @@ -333,10 +333,11 @@ def get_redis() -> Redis | None: Returns: The asynchronous redis client. """ - if isinstance((redis_url_or_options := parse_redis_url()), str): - return Redis.from_url(redis_url_or_options) - elif isinstance(redis_url_or_options, dict): - return Redis(**redis_url_or_options) + if (redis_url := parse_redis_url()) is not None: + return Redis.from_url( + redis_url, + retry_on_error=[RedisError], + ) return None @@ -346,14 +347,15 @@ def get_redis_sync() -> RedisSync | None: Returns: The synchronous redis client. """ - if isinstance((redis_url_or_options := parse_redis_url()), str): - return RedisSync.from_url(redis_url_or_options) - elif isinstance(redis_url_or_options, dict): - return RedisSync(**redis_url_or_options) + if (redis_url := parse_redis_url()) is not None: + return RedisSync.from_url( + redis_url, + retry_on_error=[RedisError], + ) return None -def parse_redis_url() -> str | dict | None: +def parse_redis_url() -> str | None: """Parse the REDIS_URL in config if applicable. Returns: @@ -387,7 +389,7 @@ async def get_redis_status() -> dict[str, bool | None]: redis_client.ping() else: status = None - except exceptions.RedisError: + except RedisError: status = False return {"redis": status} diff --git a/reflex/vars/base.py b/reflex/vars/base.py index 094a478c8..a4496d77d 100644 --- a/reflex/vars/base.py +++ b/reflex/vars/base.py @@ -127,7 +127,7 @@ class VarData: state: str = "", field_name: str = "", imports: ImportDict | ParsedImportDict | None = None, - hooks: dict[str, None] | None = None, + hooks: dict[str, VarData | None] | None = None, deps: list[Var] | None = None, position: Hooks.HookPosition | None = None, ): @@ -194,7 +194,9 @@ class VarData: (var_data.state for var_data in all_var_datas if var_data.state), "" ) - hooks = {hook: None for var_data in all_var_datas for hook in var_data.hooks} + hooks: dict[str, VarData | None] = { + hook: None for var_data in all_var_datas for hook in var_data.hooks + } _imports = imports.merge_imports( *(var_data.imports for var_data in all_var_datas) @@ -2276,7 +2278,7 @@ def computed_var( def computed_var( fget: Callable[[BASE_STATE], Any] | None = None, initial_value: Any | types.Unset = types.Unset(), - cache: bool = False, + cache: Optional[bool] = None, deps: Optional[List[Union[str, Var]]] = None, auto_deps: bool = True, interval: Optional[Union[datetime.timedelta, int]] = None, @@ -2302,6 +2304,15 @@ def computed_var( ValueError: If caching is disabled and an update interval is set. VarDependencyError: If user supplies dependencies without caching. """ + if cache is None: + cache = False + console.deprecate( + "Default non-cached rx.var", + "the default value will be `@rx.var(cache=True)` in a future release. " + "To retain uncached var, explicitly pass `@rx.var(cache=False)`", + deprecation_version="0.6.8", + removal_version="0.7.0", + ) if cache is False and interval is not None: raise ValueError("Cannot set update interval without caching.") diff --git a/tests/integration/test_lifespan.py b/tests/integration/test_lifespan.py index cb6c640ab..0fa4a7e92 100644 --- a/tests/integration/test_lifespan.py +++ b/tests/integration/test_lifespan.py @@ -43,6 +43,8 @@ def LifespanApp(): lifespan_task_global = 0 class LifespanState(rx.State): + interval: int = 100 + @rx.var def task_global(self) -> int: return lifespan_task_global @@ -59,7 +61,15 @@ def LifespanApp(): return rx.vstack( rx.text(LifespanState.task_global, id="task_global"), rx.text(LifespanState.context_global, id="context_global"), - rx.moment(interval=100, on_change=LifespanState.tick), + rx.button( + rx.moment( + interval=LifespanState.interval, on_change=LifespanState.tick + ), + on_click=LifespanState.set_interval( # type: ignore + rx.cond(LifespanState.interval, 0, 100) + ), + id="toggle-tick", + ), ) app = rx.App() @@ -108,6 +118,7 @@ async def test_lifespan(lifespan_app: AppHarness): original_task_global_text = task_global.text original_task_global_value = int(original_task_global_text) lifespan_app.poll_for_content(task_global, exp_not_equal=original_task_global_text) + driver.find_element(By.ID, "toggle-tick").click() # avoid teardown errors assert lifespan_app.app_module.lifespan_task_global > original_task_global_value # type: ignore assert int(task_global.text) > original_task_global_value diff --git a/tests/integration/tests_playwright/test_link_hover.py b/tests/integration/tests_playwright/test_link_hover.py new file mode 100644 index 000000000..9510bd358 --- /dev/null +++ b/tests/integration/tests_playwright/test_link_hover.py @@ -0,0 +1,46 @@ +from typing import Generator + +import pytest +from playwright.sync_api import Page, expect + +from reflex.testing import AppHarness + + +def LinkApp(): + import reflex as rx + + app = rx.App() + + def index(): + return rx.vstack( + rx.box(height="10em"), # spacer, so the link isn't hovered initially + rx.link( + "Click me", + href="#", + color="blue", + _hover=rx.Style({"color": "red"}), + ), + ) + + app.add_page(index, "/") + + +@pytest.fixture() +def link_app(tmp_path_factory) -> Generator[AppHarness, None, None]: + with AppHarness.create( + root=tmp_path_factory.mktemp("link_app"), + app_source=LinkApp, # type: ignore + ) as harness: + assert harness.app_instance is not None, "app is not running" + yield harness + + +def test_link_hover(link_app: AppHarness, page: Page): + assert link_app.frontend_url is not None + page.goto(link_app.frontend_url) + + link = page.get_by_role("link") + expect(link).to_have_text("Click me") + expect(link).to_have_css("color", "rgb(0, 0, 255)") + link.hover() + expect(link).to_have_css("color", "rgb(255, 0, 0)")