Skip to content

Compiler hooks memo#6288

Open
FarhanAliRaza wants to merge 17 commits intoreflex-dev:mainfrom
FarhanAliRaza:compiler-hooks-memo
Open

Compiler hooks memo#6288
FarhanAliRaza wants to merge 17 commits intoreflex-dev:mainfrom
FarhanAliRaza:compiler-hooks-memo

Conversation

@FarhanAliRaza
Copy link
Copy Markdown
Collaborator

All Submissions:

Replace StatefulComponent with {children}-pass-through memo wrappers

Delete the legacy StatefulComponent auto-memoization system (compile_from
pre-walk, shared stateful_components.jsx file, references/rendered_as_shared
bookkeeping) and replace it with MemoizeStatefulPlugin that participates in
the single-pass tree walk.

At each memoizable call-site, the plugin wraps the stateful component with
a MemoWrapperComponent that renders as {children} and emits
const Tag = memo(({children}) => children) as page-level custom code.
Event-trigger useCallback hooks are hoisted into the page body. The walker
descends into the wrapped subtree normally, so DefaultCollectorPlugin still
sees the original components for import/hook collection.

Removes:

  • StatefulComponent class (~470 lines)
  • _compile_stateful_components, _get_shared_components_recursive,
    compile_stateful_components, _compile_memoized_components
  • get_stateful_components_path, stateful_component_template,
    stateful_components_template
  • stateful_component kwarg and _compiler_stateful_only_leave_component
    flag throughout the plugin hook signature chain
  • stateful_components_path/code fields on CompileContext

Adds:

  • MemoizeStatefulPlugin + MemoWrapperComponent

  • memoize_helpers module in reflex-base (shared with window_events/upload)

  • 12 unit tests for the new plugin

  • 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.

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

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 #6213

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.
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.
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.
Delete the legacy StatefulComponent auto-memoization system (compile_from
pre-walk, shared stateful_components.jsx file, references/rendered_as_shared
bookkeeping) and replace it with MemoizeStatefulPlugin that participates in
the single-pass tree walk.

At each memoizable call-site, the plugin wraps the stateful component with
a MemoWrapperComponent that renders as <Tag>{children}</Tag> and emits
`const Tag = memo(({children}) => children)` as page-level custom code.
Event-trigger useCallback hooks are hoisted into the page body. The walker
descends into the wrapped subtree normally, so DefaultCollectorPlugin still
sees the original components for import/hook collection.

Removes:
- StatefulComponent class (~470 lines)
- _compile_stateful_components, _get_shared_components_recursive,
  compile_stateful_components, _compile_memoized_components
- get_stateful_components_path, stateful_component_template,
  stateful_components_template
- stateful_component kwarg and _compiler_stateful_only_leave_component
  flag throughout the plugin hook signature chain
- stateful_components_path/code fields on CompileContext

Adds:
- MemoizeStatefulPlugin + MemoWrapperComponent
- memoize_helpers module in reflex-base (shared with window_events/upload)
- 12 unit tests for the new plugin
@codspeed-hq
Copy link
Copy Markdown

codspeed-hq bot commented Apr 5, 2026

Merging this PR will improve performance by ×11

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

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

Performance Changes

Benchmark BASE HEAD Efficiency
test_get_all_imports[_stateful_page] 3,079.3 µs 499 µs ×6.2
🆕 test_compile_page_full_context[_stateful_page] N/A 31.4 ms N/A
test_compile_page[_stateful_page] 10.7 ms 1.7 ms ×6.3
🆕 test_compile_page_full_context[_complicated_page] N/A 168.7 ms N/A
🆕 test_get_all_imports_single_pass[_stateful_page] N/A 772 µs N/A
🆕 test_compile_page_single_pass[_complicated_page] N/A 35.1 ms N/A
🆕 test_compile_single_pass_all_artifacts[_stateful_page] N/A 13 ms N/A
🆕 test_evaluate_page_single_pass[_complicated_page] N/A 46.6 ms N/A
🆕 test_evaluate_page_single_pass[_stateful_page] N/A 7.3 ms N/A
🆕 test_compile_page_single_pass[_stateful_page] N/A 13.8 ms N/A
🆕 test_get_all_imports_single_pass[_complicated_page] N/A 3.3 ms N/A
🆕 test_compile_single_pass_all_artifacts[_complicated_page] N/A 33.9 ms N/A
test_get_all_imports[_complicated_page] 22.9 ms 2.5 ms ×9.3
test_compile_page[_complicated_page] 90.6 ms 8.2 ms ×11

Comparing FarhanAliRaza:compiler-hooks-memo (58a86f9) with main (1110287)

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.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 5, 2026

Greptile Summary

This PR is a significant architectural refactor that replaces the legacy StatefulComponent auto-memoization system (~470 lines) with a new MemoizeStatefulPlugin participating in the compiler's single-pass tree walk. Instead of emitting a shared stateful_components.jsx file, each memoizable call-site is wrapped in a per-page const Tag = memo(({children}) => children) declaration, and useCallback hooks for event triggers are hoisted to the page body. The change is well-structured and backed by 12 new unit tests.

  • StatefulComponent (~470 lines) and all related functions, templates, and bookkeeping fields removed; replaced by MemoizeStatefulPlugin + MemoWrapperComponent
  • New memoize_helpers.py module extracted into reflex-base, shared between the compiler plugin, WindowEventListener, and the upload component
  • Component.render() gains a _cached_render_result cache; correctly invalidated by invalidate_event_trigger_caches after event-trigger mutation
  • P1: copy.copy(component) in fix_event_triggers_for_memo is a no-op — event_triggers is a shared mutable dict; any component instance compiled more than once (across pages) will have already-mutated Var refs fed to get_memoized_event_triggers, producing incorrect useCallback dependency arrays
  • P2: page_context._memoize_suppress_depth -= 1 in leave_component is unguarded; a MemoizationLeaf reused across pages would arrive on a fresh PageContext that never initialized _memoize_suppress_depth, raising AttributeError
  • 12 unit tests cover core plugin decisions well, but the cross-page shared-instance scenario (where both issues manifest) is not exercised

Confidence Score: 4/5

Safe to merge for typical single-page components; shared component instances compiled across multiple pages may receive incorrect useCallback wrappers due to the dead shallow-copy in fix_event_triggers_for_memo.

One P1 logic bug (copy.copy dead code in fix_event_triggers_for_memo permitting silent mutation of the shared event_triggers dict on reused instances) should be addressed before merge. All other findings are P2 style/fragility concerns that do not block the typical use case.

packages/reflex-base/src/reflex_base/components/memoize_helpers.py (fix_event_triggers_for_memo shallow-copy bug) and reflex/compiler/plugins/memoize.py (_memoize_suppress_depth unguarded decrement)

Important Files Changed

Filename Overview
packages/reflex-base/src/reflex_base/components/memoize_helpers.py New shared memoization-helper module; P1 bug in fix_event_triggers_for_memo — copy.copy() guard is a no-op (shallow copy shares the event_triggers dict), so shared component instances compiled across pages receive incorrect useCallback wrappers.
reflex/compiler/plugins/memoize.py New MemoizeStatefulPlugin replacing StatefulComponent; MemoizationLeaf suppression-counter logic is correct for single-page use, but _memoize_suppress_depth decrement is unguarded against component instances reused on a fresh PageContext.
reflex/compiler/plugins/builtin.py Clean plugin registration; MemoizeStatefulPlugin correctly inserted between ApplyStylePlugin and DefaultCollectorPlugin in default_page_plugins.
packages/reflex-base/src/reflex_base/compiler/templates.py Mechanical rename of stateful_component_template / stateful_components_template to dynamic_component_template / dynamic_components_module_template; no logic changes.
packages/reflex-base/src/reflex_base/components/component.py Component.render() gains _cached_render_result cache; correctly invalidated by invalidate_event_trigger_caches after event-trigger mutation, but any future mutation of component props outside that path would silently return stale renders.
packages/reflex-base/src/reflex_base/plugins/compiler.py CompileContext.memoize_wrappers dict added cleanly and correctly accessed by MemoizeStatefulPlugin.enter_component for cross-page tag deduplication.
tests/units/compiler/test_memoize_plugin.py 12 well-structured unit tests covering _should_memoize, tag deduplication, leaf suppression, re-entry guard, and hook emission; the cross-page shared-instance scenario where both bugs manifest is not tested.

Sequence Diagram

sequenceDiagram
    participant Walker
    participant Plugin as MemoizeStatefulPlugin
    participant Helpers as memoize_helpers
    participant Comp as Component
    participant PageCtx as PageContext
    participant CompileCtx as CompileContext

    Walker->>Plugin: enter_component(comp)
    Plugin->>Comp: getattr(_memoize_wrapped)
    alt _memoize_wrapped is True (re-entry guard)
        Plugin->>Comp: del _memoize_wrapped
        Plugin-->>Walker: None (pass-through)
    else normal first visit
        Plugin->>Plugin: _should_memoize(comp)
        Plugin->>Comp: comp.render() — cached as _cached_render_result
        Plugin->>Plugin: _compute_memo_tag → stable tag name
        Plugin->>Helpers: fix_event_triggers_for_memo(comp)
        Note over Helpers,Comp: copy.copy is a no-op; event_triggers dict is shared with original
        Helpers->>Comp: comp.event_triggers[trigger] = memo_var
        Helpers-->>Plugin: [useCallback hook lines]
        Plugin->>Helpers: invalidate_event_trigger_caches(comp)
        Helpers->>Comp: del _cached_render_result, _vars_cache, _imports_cache, _hooks_internal_cache
        Plugin->>PageCtx: hooks[hook_line] = None
        Plugin->>CompileCtx: memoize_wrappers[tag] = None
        Plugin->>Comp: _memoize_wrapped = True
        Plugin->>Plugin: wrapper = MemoWrapperComponent._create(children=[comp])
        Plugin-->>Walker: (wrapper, (comp,))
        Walker->>Plugin: enter_component(comp) [re-entry as child of wrapper]
        Plugin->>Comp: _memoize_wrapped is True — del it
        Plugin-->>Walker: None
        Walker->>Walker: descend into comp.children normally
        Walker->>Plugin: leave_component(comp, ...)
        Plugin->>Comp: getattr(_memoize_pushed_suppression)
        alt suppression was pushed for this leaf
            Plugin->>PageCtx: _memoize_suppress_depth -= 1
            Note over Plugin,PageCtx: Unguarded — raises AttributeError if PageContext is fresh (reused component)
            Plugin->>Comp: del _memoize_pushed_suppression
        end
        Plugin-->>Walker: None
    end
Loading

Reviews (1): Last reviewed commit: "Replace StatefulComponent with {children..." | Re-trigger Greptile

FarhanAliRaza and others added 4 commits April 5, 2026 22:53
Override _validate_component_children on the memoize wrapper to be a
no-op. The wrapper is a transparent compile-time insertion, so the
wrapped child"s _valid_parents constraint is already satisfied by the
original user-authored parent (e.g. SelectGroup inside SelectContent) —
the wrapper should not interpose on that relationship.

Fixes ValueError from integration tests like test_form_submit where
`SelectGroup can only be a child of SelectContent` fires when the
MemoizeStatefulPlugin wraps SelectGroup with a MemoWrapper during
compilation.
…s.py

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Single-pass compiler: Replace StatefulComponent auto-memoization with rx._x.memo-based approach

1 participant