Skip to content

Add compiler plugin hooks and plugins and move compilation pipeline out of App#6260

Merged
masenf merged 72 commits into
reflex-dev:mainfrom
FarhanAliRaza:compiler-hooks
Apr 30, 2026
Merged

Add compiler plugin hooks and plugins and move compilation pipeline out of App#6260
masenf merged 72 commits into
reflex-dev:mainfrom
FarhanAliRaza:compiler-hooks

Conversation

@FarhanAliRaza
Copy link
Copy Markdown
Contributor

@FarhanAliRaza FarhanAliRaza commented Mar 31, 2026

Summary

  • Introduce a CompilerPlugin protocol with enter_component/leave_component/eval_page/compile_page hooks, enabling third-party plugins to participate in the frontend compilation pipeline
  • Move the frontend compilation pipeline from App._compile() into compiler.compile_app(), decoupling compilation logic from the App class
  • Remove ExecutorType/ExecutorSafeFunctions abstractions from environment.py in favor of a sequential plugin-driven compilation model
  • Add built-in plugins: DefaultPagePlugin, ApplyStylePlugin, DefaultCollectorPlugin that replicate the existing compilation behavior
  • Fix memo component compilation ordering so app-wrap components are included
  • Fix DefaultPagePlugin to preserve Var-backed page titles instead of replacing them with the default string

Key Changes

  • packages/reflex-core/src/reflex_core/plugins/compiler.py (new): Core plugin infrastructure — CompilerPlugin protocol, CompileContext, PageContext, CompilerHooks, and component tree traversal logic
  • reflex/compiler/plugins/builtin.py (new): Built-in plugins (DefaultPagePlugin, ApplyStylePlugin, DefaultCollectorPlugin) that replicate existing compilation behavior
  • reflex/compiler/compiler.py: Refactored compile_app() to use plugin-driven compilation via CompilerHooks
  • reflex/app.py: Removed ~430 lines of compilation logic, App._compile() now delegates to compiler.compile_app()
  • packages/reflex-core/src/reflex_core/environment.py: Removed ExecutorType and ExecutorSafeFunctions (no longer needed)

Test Plan

  • New unit tests in tests/units/compiler/test_plugins.py (855 lines) covering:
    • Plugin hook dispatch (enter/leave component, eval/compile page)
    • CompileContext lifecycle and state management
    • DefaultPagePlugin behavior including Var-backed titles
    • ApplyStylePlugin style application
    • DefaultCollectorPlugin import/custom code collection
    • Route deduplication and error handling
    • End-to-end compilation matching legacy output
  • New test in tests/units/test_app.py verifying memo components are written to shared components module
  • Existing test suite passes without modification

All Submissions:

  • Have you followed the guidelines stated in CONTRIBUTING.md file?
  • Have you checked to ensure there aren't any other open Pull Requests for the desired changed?

Type of change

Please delete options that are not relevant.

  • New feature (non-breaking change which adds functionality)

New Feature Submission:

  • Does your submission pass the tests?
  • Have you linted your code locally prior to submission?

Changes To Core Features:

  • Have you added an explanation of what your changes do and why you'd like us to include them?
  • Have you written new tests for your core changes, as applicable?
  • Have you successfully ran tests with your changes locally?

closes #6210
closes #6211
closes #6212
Closes #6213
Closes #6325

@FarhanAliRaza FarhanAliRaza marked this pull request as draft March 31, 2026 16:50
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 31, 2026

Greptile Summary

This PR replaces Reflex's frontend compilation pipeline with a plugin-driven architecture: a CompilerPlugin protocol with enter_component/leave_component/eval_page/compile_page hooks, a single-pass CompileContext/PageContext state machine, and built-in plugins (DefaultPagePlugin, ApplyStylePlugin, DefaultCollectorPlugin, MemoizeStatefulPlugin, RadixThemesPlugin) that replicate and extend the old App._compile() behavior. The ExecutorSafeFunctions/ExecutorType parallelism abstractions and the monolithic stateful-components module are removed. All findings are P2.

Confidence Score: 5/5

Safe to merge; no P0/P1 findings, only minor style and edge-case suggestions.

All identified issues are P2. The core pipeline logic is well-structured, the sequential execution model eliminates the concurrency concerns of the old executor abstraction, and the test suite is extensive (2000+ new test lines). The deprecated App.theme discard and the re-walk double-apply are known/documented trade-offs.

packages/reflex-base/src/reflex_base/plugins/compiler.py (_compile_component_with_replacements re-walk behavior) and packages/reflex-components-radix/src/reflex_components_radix/plugin.py (apply_app_theme silent discard)

Important Files Changed

Filename Overview
packages/reflex-base/src/reflex_base/plugins/compiler.py New core plugin infrastructure: CompilerPlugin protocol, CompileContext, PageContext, CompilerHooks, and multi-strategy component tree traversal. Well-engineered but re-walking on leave-hook replacements can double-apply stateful plugin side effects.
reflex/compiler/compiler.py Refactored compile_app() now drives the full plugin pipeline; removes ExecutorSafeFunctions, per-memo files replace the single shared component module, and compile_page_from_context uses a falsy-dict or-fallback that could be misleading.
reflex/compiler/plugins/builtin.py Built-in DefaultPagePlugin, ApplyStylePlugin, DefaultCollectorPlugin replicate legacy compilation behavior. The bound fast-path leave hook uses seen_app_wrap_methods dedup while the unbound path does not — minor inconsistency, no correctness issue.
packages/reflex-components-radix/src/reflex_components_radix/plugin.py New RadixThemesPlugin auto-enables on first RadixThemesComponent; silently discards App(theme=...) when an explicit plugin is already configured, which may surprise users during migration from the deprecated API.
reflex/compiler/plugins/memoize.py New MemoizeStatefulPlugin replaces legacy StatefulComponent wrapping; uses qualname+hash for stable memo tags and correctly separates passthrough vs snapshot memoization strategies.
reflex/app.py Removed ~430 lines of compilation logic; _compile() now delegates entirely to compiler.compile_app(). UnevaluatedPage fields given defaults, App.theme defaulted to None (Radix no longer always-on).
packages/reflex-base/src/reflex_base/environment.py Removed ExecutorType, ExecutorSafeFunctions, and related REFLEX_COMPILE_EXECUTOR/PROCESSES/THREADS env vars. Clean removal of the parallelism abstraction that the plugin pipeline supersedes.

Sequence Diagram

sequenceDiagram
    participant App
    participant compile_app
    participant CompileContext
    participant CompilerHooks
    participant DefaultPagePlugin
    participant ApplyStylePlugin
    participant DefaultCollectorPlugin
    participant MemoizeStatefulPlugin
    participant RadixThemesPlugin

    App->>compile_app: _compile()
    compile_app->>compile_app: _resolve_radix_themes_plugin()
    compile_app->>CompileContext: create(hooks=CompilerHooks(plugins))
    compile_app->>CompileContext: compile(evaluate_progress, render_progress)

    loop For each page
        CompileContext->>CompilerHooks: eval_page(page_fn, page)
        CompilerHooks->>DefaultPagePlugin: eval_page() → PageContext
        DefaultPagePlugin-->>CompileContext: PageContext

        CompileContext->>CompilerHooks: compile_component(root, page_ctx)
        CompilerHooks->>RadixThemesPlugin: enter_component (auto-enable)
        CompilerHooks->>ApplyStylePlugin: enter_component (apply style)
        CompilerHooks->>MemoizeStatefulPlugin: enter_component (wrap stateful)
        CompilerHooks->>DefaultCollectorPlugin: leave_component (collect imports/hooks)

        CompileContext->>CompilerHooks: compile_page(page_ctx)
        CompilerHooks->>DefaultCollectorPlugin: compile_page (collapse imports)
        CompilerHooks->>RadixThemesPlugin: compile_page (inject theme wrap)

        CompileContext->>compile_app: page_ctx.output_path, output_code
    end

    compile_app->>compile_app: _resolve_app_wrap_components()
    compile_app->>compile_app: compile_memo_components()
    compile_app->>compile_app: compile_root_stylesheet(plugins)
    compile_app->>compile_app: write all outputs to disk
Loading

Reviews (3): Last reviewed commit: "Address CR feedback" | Re-trigger Greptile

Comment thread tests/benchmarks/test_compilation.py Outdated
Comment thread reflex/compiler/plugins.py Outdated
@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented Mar 31, 2026

Merging this PR will improve performance by ×11

⚡ 5 improved benchmarks
✅ 2 untouched benchmarks
🆕 10 new benchmarks
⏩ 2 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
test_compile_page[_complicated_page] 90.4 ms 8.4 ms ×11
🆕 test_evaluate_page_single_pass[_complicated_page] N/A 39.8 ms N/A
test_get_all_imports[_stateful_page] 3,163.8 µs 550 µs ×5.8
test_evaluate_page[_complicated_page] 46.8 ms 38.9 ms +20.38%
🆕 test_compile_page_full_context[_complicated_page] N/A 147.6 ms N/A
🆕 test_evaluate_page_single_pass[_stateful_page] N/A 7.3 ms N/A
🆕 test_compile_page_full_context[_stateful_page] N/A 15.9 ms N/A
🆕 test_get_all_imports_single_pass[_stateful_page] N/A 448.8 µs N/A
🆕 test_get_all_imports_single_pass[_complicated_page] N/A 1.6 ms N/A
test_compile_page[_stateful_page] 10.7 ms 1.7 ms ×6.2
🆕 test_compile_page_single_pass[_stateful_page] N/A 13.3 ms N/A
test_get_all_imports[_complicated_page] 22.8 ms 2.7 ms ×8.3
🆕 test_compile_page_single_pass[_complicated_page] N/A 139.4 ms N/A
🆕 test_compile_single_pass_all_artifacts[_complicated_page] N/A 134.7 ms N/A
🆕 test_compile_single_pass_all_artifacts[_stateful_page] N/A 13 ms N/A

Comparing FarhanAliRaza:compiler-hooks (67a450d) with main (0f46c0e)

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@FarhanAliRaza FarhanAliRaza marked this pull request as ready for review April 1, 2026 11:07
@FarhanAliRaza FarhanAliRaza requested a review from masenf April 1, 2026 11:08
Copy link
Copy Markdown
Collaborator

@masenf masenf left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need a test case for the child replacement logic when traversing the tree; we might not strictly need it now, but it will be important when moving the StatefulComponent compilation into the plugin system.

Comment thread reflex/compiler/plugins.py Outdated
@masenf
Copy link
Copy Markdown
Collaborator

masenf commented Apr 2, 2026

Khaleel and I were discussing this a bit further, and I think it would be better to add these new plugin hooks and hook dispatching system to the existing Plugin base in packages/reflex-core

Move the frontend compilation pipeline from App._compile into
compiler.compile_app(), introducing a CompilerPlugin protocol with
enter_component/leave_component/eval_page/compile_page hooks. Remove
the ExecutorType/ExecutorSafeFunctions abstractions in favor of a
sequential plugin-driven compilation model.
@FarhanAliRaza FarhanAliRaza changed the title Add single-pass component tree collector and compiler plugin foundations Add compiler plugin hooks and plugins and move compilation pipeline out of App Apr 3, 2026
Move memo component compilation after app_root resolution so app-wrap
components are included. Fix DefaultPagePlugin to preserve Var-backed
titles instead of replacing them with the default string.
Move _get_app_wrap_components collection outside the
`if stateful_component is None` guard so that app wrap components
(e.g. UploadFilesProvider) are collected even when a component is
wrapped as a stateful component. Add test verifying upload pages
correctly emit UploadFilesProvider in the app root.
@FarhanAliRaza
Copy link
Copy Markdown
Contributor Author

@masenf i dont know why the tests are failing.

Remove the class-level tag_to_stateful_component dict and instead
thread a compile-scoped stateful_component_cache through compile_from()
and create(). This prevents stale cache entries from leaking between
independent compilation runs. Also collect imports and app_wrap_components
from root components in CompileContext so stateful component libraries
and providers (e.g. UploadFilesProvider) are properly propagated.
Update benchmarks to inline helpers using the new plugin API and add
tests covering shared stateful components across pages and cache
isolation between runs.
Component._get_vars had a dead-code cache path: `getattr(self, "__vars", None)`
reads the literal attribute `__vars` but `self.__vars = []` writes to the
name-mangled `_Component__vars`. The cache branch was never taken, and even if
the name-mangling were fixed the missing `return` after `yield from vars` would
have caused duplicate yields on repeated calls.

Fix the cache (as `_vars_cache`) with a proper early-return. Extend the same
per-instance cache pattern to `_get_imports` and `_get_hooks_internal`, which
share the same dependency on `event_triggers` / `_get_vars`. Unify invalidation
with the existing render-cache clear in `StatefulComponent.create` so all four
caches drop together when `_fix_event_triggers` mutates the component.
Returns:
A page context when the plugin can evaluate the page, otherwise ``None``.
"""
del page_fn, kwargs
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why does it del the local vars?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are gonna be overloaded by the actual plugin. So Claude is i think, just trying to use it. Deleting it.

]


class CompilerPlugin(Protocol):
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this protocol needed since we have the base plugin definition?



@dataclasses.dataclass(kw_only=True)
class BaseContext:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this class also gets defined in my backend event loop PR as reflex_base.context.base depending on whose merges first, we can refactor

if isinstance(page_ctx.root_component, StatefulComponent):
self.all_imports = merge_imports(
self.all_imports,
page_ctx.root_component._get_all_imports(),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this still making the recursive call? isn't this information cached in the PageContext as imports?

…ields

Remove the duplicated _compile_component_without_replacements and
_compile_component_single_enter_fast_path methods in favor of a single
_compile_component_tree walker with inline replacement checks. This
eliminates ~200 lines of duplication and removes several optimization-only
fields (_regular_leave_component_hook_binders, _stateful_leave_component_hook_binders,
_component_hooks_can_replace, _enter_component_hooks, _leave_component_hooks)
with negligible benchmark impact.
_compile_stateful_components now returns its collected imports alongside
the rendered code. CompileContext merges these into all_imports after
compilation, and no longer calls _get_all_imports on the root component
(the single-pass walk already collects structural imports).
CompilerPlugin duplicated methods already on Plugin base class.
PageDefinition was a structural typing protocol only satisfied by
UnevaluatedPage. Replace both with their concrete counterparts.

Give UnevaluatedPage fields defaults so it can be used directly in
tests and benchmarks, eliminating FakePage and BenchmarkPage classes.
# Conflicts:
#	packages/reflex-base/src/reflex_base/components/component.py
#	reflex/app.py
Copy link
Copy Markdown
Collaborator

@masenf masenf left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i noticed another problem here with Foreach and Cond, and I think the fix is similar to what i did for Bare: basically treat these components as MemoizationLeaf and extract them to their own memoized component without the {children} hole.

When we're compiling out a Foreach, Cond, or Match, these need to be extracted as their own auto-memo component if they are stateful. And to ensure that nested Foreach is working, we need to treat the first one we encounter in the tree as a MemoizationLeaf so the nested Foreach can access the outer Foreach loop variables.

Ideally we have some way of separately memoizing these and passing the loop props down if needed, but that's a lot of extra complexity, so we should just get the base case working in the same way the current compiler does it.

The main difference between the approach here and the current compiler is that the current compiler would treat the parent component as stateful if any of its children are Foreach/Cond/Match, but that's not really appropriate any more with the {children} passthrough being the default, so these really should be memoized as Fragment containing their contents.

# template.
snapshot_only = is_snapshot_boundary(component)

captured_hole_child: list[Component] = []
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this a list[Component] and not Component | None? The latter seems more semantically accurate to how it's used.

Consolidate auto-memo render strategy classification so snapshot bodies are used for MemoizationLeaf components and structural Foreach/Cond/Match forms, while passthrough components like forms keep their children hole for ref collection.

Add regression tests proving special-form state hooks render inside generated memo components instead of leaking into the page, and update the automemo_upload example to validate stateful, upload, form, and special-form memoization output.
@FarhanAliRaza FarhanAliRaza requested a review from a team as a code owner April 24, 2026 14:01
masenf and others added 8 commits April 29, 2026 01:12
When Cond or Match cases contain stateful children, auto-memoize these
separately from the component to avoid unnecessary mixing of hooks.
The previous implementation rebuilt nested `str([...])` representations at every recursion level and md5-hashed each leaf, dominating the auto- memo tagging path. Replacing it with one type-tagged, length-prefixed walker over a single hasher drops hashing samples ~3x in flexgen compile (~8% wall on top of the prior merge_imports fix).
Add functional test cases for Match and Cond in an integration test
Avoid special cases for these components. Instead, map the special children
prop to the conditional cases at render time. Even if the children prop is a
frontend placeholder from an experimental memo component, it will still be
indexed and render properly.
@masenf
Copy link
Copy Markdown
Collaborator

masenf commented Apr 29, 2026

With the latest commits, we are very close. I'm noticing a few remaining problems that shouldn't be too hard to fix:

MemoizationLeaf components should still be memoized if anything in their subtree is stateful. i.e. AccordionTrigger is not getting memoized, even though the trigger contains a cond that depends on a state variable.

I think the title and meta components (and potentially other html components) should be marked as MemoizationLeaf so we don't end up memoizing a stateful Bare that is passed to them. These elements cannot have children, so they don't render properly when we do this for example:

jsx("title", {}, jsx(Bare_comp_cbb10fd3740c4ca79b576489b0269aed, {})),

We should get two test cases and fixes for these.

I'm signing off for now, but I might push commits in a few hours if I don't see anything here.

masenf and others added 4 commits April 28, 2026 22:02
Copy the original component's `_get_all_refs` onto the memo wrapper so that
handleSubmit can be properly generated, even when the form itself is moved to
its own passthrough-memoized component that doesn't actually know its children.
Snapshot boundaries (recursive=False) suppress descendants but were not themselves wrapped when their state lived in a child, so the state read leaked into the page module instead of the boundary's snapshot body. _should_memoize now walks descendants and wraps the boundary when any carry state-bearing or hooks-bearing Var data, event_triggers, or explicit user hooks. Refs from a static id prop are intentionally ignored so static-id-only elements stay unwrapped. The walk uses an id() seen-set so var_data.components reachable via multiple Vars (e.g. Foreach renderers) is visited once.

Mark void/raw-text HTML elements (title, meta, style, textarea, script, br, hr, img, input, link, base, area, col, embed, source, track, wbr, plus SVG desc/title/script/style and base.RawLink/ScriptTag) as recursive=False so a stateful Bare child stays an inline text interpolation rather than rendering as jsx(El, {}, jsx(Bare_xxx, {})), which is invalid for void elements and stringifies to [object Object] for raw-text ones. Shared constant lives in constants.compiler.

Make Component._get_vars(include_children=True) yield per child so the new subtree scan can short-circuit on first hit instead of materialising the whole subtree.
Copy link
Copy Markdown
Collaborator

@masenf masenf left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in test code, imports should be at the top of the module. the only exception to this is integration tests which need imports in the app function because that ends up getting written out to its own module by the AppHarness

Comment thread tests/units/compiler/test_memoize_plugin.py
Comment thread tests/units/compiler/test_memoize_plugin.py Outdated
Comment thread tests/integration/tests_playwright/test_memoize_edge_cases.py Outdated
Comment thread reflex/compiler/plugins/memoize.py Outdated
Comment thread packages/reflex-components-core/src/reflex_components_core/base/link.py Outdated
Comment thread tests/units/compiler/test_memoize_plugin.py
Comment thread tests/units/compiler/test_memoize_plugin.py Outdated
Comment thread tests/units/compiler/test_memoize_plugin.py Outdated
Comment thread tests/units/compiler/test_memoize_plugin.py Outdated
Comment thread tests/units/compiler/test_memoize_plugin.py Outdated
@masenf
Copy link
Copy Markdown
Collaborator

masenf commented Apr 29, 2026

@greptile-apps re-review

@masenf
Copy link
Copy Markdown
Collaborator

masenf commented Apr 30, 2026

ugh just realized that dynamic components are broken with this change

import reflex as rx


class State(rx.State):
    count: int = 0

    @rx.event
    def set_count(self, count: int):
        self.count = count

    @rx.var
    def counter_ui(self) -> rx.Component:
        return rx.hstack(
            rx.button("-", on_click=State.set_count(self.count - 1)),
            rx.text(self.count, size="9"),
            rx.button("+", on_click=State.set_count(self.count + 1)),
            spacing="5",
            justify="center",
        )


def index() -> rx.Component:
    return rx.container(
        rx.color_mode.button(position="top-right"),
        rx.vstack(
            rx.heading("Welcome to Reflex!", size="9"),
            State.counter_ui,
        ),
    )


app = rx.App()
app.add_page(index)

This counter actually works on 0.9.1, but doesn't render the event handlers with this PR.

Generated code on this PR:

//__reflex_evaluate
const React = window.__reflex.react;
const { jsx } = window.__reflex["@emotion/react"];
const { Fragment, useEffect } = window.__reflex["react"];
const {
  Button: RadixThemesButton,
  Flex: RadixThemesFlex,
  Text: RadixThemesText,
} = window.__reflex["@radix-ui/themes"];

export default function MySSRComponent() {
  return jsx(
    RadixThemesFlex,
    {
      align: "start",
      className: "rx-Stack",
      direction: "row",
      justify: "center",
      gap: "5",
    },
    jsx(RadixThemesButton, {}, "-"),
    jsx(RadixThemesText, { as: "p", size: "9" }, -1),
    jsx(RadixThemesButton, {}, "+"),
  );
}

Generated code on 0.9.1

//__reflex_evaluate
const React = window.__reflex.react;
const { jsx } = window.__reflex["@emotion/react"];
const { Fragment, useContext, useEffect } = window.__reflex["react"];
const {
  Button: RadixThemesButton,
  Flex: RadixThemesFlex,
  Text: RadixThemesText,
} = window.__reflex["@radix-ui/themes"];
const { EventLoopContext } = window["__reflex"]["$/utils/context"];
const { ReflexEvent, applyEventActions } = window["__reflex"]["$/utils/state"];

export default function MySSRComponent() {
  const [addEvents, connectErrors] = useContext(EventLoopContext);

  return jsx(
    RadixThemesFlex,
    {
      align: "start",
      className: "rx-Stack",
      direction: "row",
      justify: "center",
      gap: "5",
    },
    jsx(
      RadixThemesButton,
      {
        onClick: (_e) =>
          addEvents(
            [
              ReflexEvent(
                "reflex___state____state.repro_dynamic_component___repro_dynamic_component____state.set_count",
                { ["count"]: 0 },
                {},
              ),
            ],
            [_e],
            {},
          ),
      },
      "-",
    ),
    jsx(RadixThemesText, { as: "p", size: "9" }, 1),
    jsx(
      RadixThemesButton,
      {
        onClick: (_e) =>
          addEvents(
            [
              ReflexEvent(
                "reflex___state____state.repro_dynamic_component___repro_dynamic_component____state.set_count",
                { ["count"]: 2 },
                {},
              ),
            ],
            [_e],
            {},
          ),
      },
      "+",
    ),
  );
}

FarhanAliRaza and others added 4 commits April 30, 2026 23:21
evalReactComponent returned the module's default export (a component function) directly, so the runtime tried to render the function as a child rather than instantiating it — event handlers on the dynamic subtree never wired up. Wrap the default export in React.createElement so the dynamic component mounts as a real element.

Adds an integration regression for a counter-style dynamic component that exercises event handlers on the dynamically rendered subtree.
…test

The codegen-level bug those changes were meant to address (dynamic
components emitting JSX without onClick / EventLoopContext wiring) was
already fixed by 3f08dd7 ("Do not auto-memoize components with
imports-only VarData"). The state.js change was a no-op for the reported
symptom and freezes the dynamic element with no props at eval time, which
breaks any consumer expecting a renderable component. Drop the runtime
change and the integration test additions; keep the codegen unit tests as
a regression guard for the memoize fix.
useState's setter treats a function argument as an updater
(setState(prev => newValue)), so passing module.default (a function)
to set_xdvxrcsn invokes MySSRComponent inside basicStateReducer,
where its useContext / useRef hooks run outside a render cycle and
trip "Rendered more hooks than during the previous render."

Wrapping in React.createElement returns an element object — useState
stores it directly without invoking it as an updater, and React
renders the element normally on the next pass, running hooks in a
proper render context.

Reverts 4d74b68. Keeps the codegen unit tests (independently
useful as a regression guard for the auto-memoize fix in 3f08dd7).
Allows better modularity in `evalReactComponent` to not assume the callers
usage of the returned value at that time.
@masenf masenf merged commit 86382e2 into reflex-dev:main Apr 30, 2026
98 of 136 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment