Skip to content

Do not memoize parent component of Foreach#6454

Open
masenf wants to merge 1 commit intomainfrom
masenf/do-not-memo-foreach-parent
Open

Do not memoize parent component of Foreach#6454
masenf wants to merge 1 commit intomainfrom
masenf/do-not-memo-foreach-parent

Conversation

@masenf
Copy link
Copy Markdown
Collaborator

@masenf masenf commented May 5, 2026

Avoid issue where broader-than-needed SNAPSHOT memoization was occuring when these components should have been memoized separately.

This is likely a benchmark regression, since before the entire stateful_page was getting wrapped up in a single memo component, so most of the compiler tree walk was getting skipped.

Avoid issue where broader-than-needed SNAPSHOT memoization was occuring when
these components should have been memoized separately.
@masenf masenf requested a review from a team as a code owner May 5, 2026 01:54
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 5, 2026

Greptile Summary

This PR fixes over-broad SNAPSHOT memoization by removing _has_memoization_snapshot_child and letting the Foreach structural child own its own snapshot wrapper, while its parent reverts to PASSTHROUGH. A companion guard in _should_memoize prevents Foreach (which has tag = None) from being short-circuited before it can be memoized.

The logic change is correct and the tests are meaningfully updated. Two minor points worth noting: test_special_form_memo_wrappers_render_structural_body does not verify wrapper count/tag, so it cannot distinguish old vs new scoping behaviour; and _is_structural_memoization_child is now imported across a package boundary while remaining absent from __all__ and carrying the private _ prefix.

Confidence Score: 4/5

Safe to merge — the core logic is correct and the critical invariants (Foreach is SNAPSHOT, parent is PASSTHROUGH) are verified by the updated unit tests.

No P0 or P1 findings. Two P2 observations: a missing wrapper-count assertion in the integration test (which means the old broken behaviour would still pass that test), and a private symbol being used across a package boundary without all declaration. Neither blocks correctness.

tests/units/compiler/test_memoize_plugin.py — the foreach integration test could be strengthened with a wrapper-count assertion to guard the regression directly.

Important Files Changed

Filename Overview
packages/reflex-base/src/reflex_base/components/memoize_helpers.py Removes _has_memoization_snapshot_child and simplifies get_memoization_strategy so a Foreach parent gets PASSTHROUGH instead of SNAPSHOT; clean and correct.
reflex/compiler/plugins/memoize.py Adds _is_structural_memoization_child exclusion in _should_memoize so Foreach (tag=None) is not short-circuited before it can be wrapped; companion fix to the strategy change.
tests/units/compiler/test_memoize_plugin.py Inverts the foreach_parent assertion (SNAPSHOT→PASSTHROUGH) and adds assertion that the Foreach child itself uses SNAPSHOT; coverage is adequate but lacks an integration-level wrapper-count assertion for the full foreach-in-box pipeline.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[enter_component / leave_component] --> B{get_memoization_strategy}
    B --> |SNAPSHOT| C{is_snapshot_boundary?}
    B --> |PASSTHROUGH| D{_should_memoize?}

    C --> |True: recursive=False boundary| E[enter_component: wrap + suppress descendants]
    C --> |False: Foreach structural child| F[enter_component: wrap Foreach as SNAPSHOT]

    D --> |True: stateful props/triggers| G[leave_component: build passthrough wrapper]
    D --> |False: no reactive data| H[leave_component: no-op, skip]

    subgraph OLD ["BEFORE"]
        direction TB
        OLD1["Parent of Foreach → SNAPSHOT\n(whole subtree in one memo)"]
    end

    subgraph NEW ["AFTER this PR"]
        direction TB
        NEW1["Parent of Foreach → PASSTHROUGH"]
        NEW2["Foreach itself → SNAPSHOT"]
    end

    style OLD fill:#ffcccc,stroke:#cc0000
    style NEW fill:#ccffcc,stroke:#007700
Loading

Comments Outside Diff (2)

  1. tests/units/compiler/test_memoize_plugin.py, line 237-252 (link)

    P2 Missing wrapper-count assertion in foreach integration test

    test_special_form_memo_wrappers_render_structural_body only checks that the state-context wiring lands in the memo module and not in the page output — it doesn't assert the number of wrappers or that the wrapper is tied to Foreach rather than its parent rx.box. The old (buggy) behaviour (one SNAPSHOT wrapper for the whole box(foreach(…)) subtree) would pass this test just as well as the new behaviour (one SNAPSHOT wrapper scoped to Foreach only), so the regression this PR fixes is not directly guarded at the integration level. Adding assert len(ctx.memoize_wrappers) == 1 plus a tag-name check (e.g. "foreach" in the tag) would lock in the corrected scope.

  2. packages/reflex-base/src/reflex_base/components/memoize_helpers.py, line 264-271 (link)

    P2 _is_structural_memoization_child imported externally but absent from __all__

    reflex/compiler/plugins/memoize.py now imports _is_structural_memoization_child directly, making it part of the module's de-facto public API surface for sibling packages. The function is excluded from __all__ and its _ prefix signals it is private. If cross-package use is intentional, adding it to __all__ would make the contract explicit; if it should stay private, re-locating the logic into memoize.py or passing it as a parameter would avoid the leak.

Reviews (1): Last reviewed commit: "Do not memoize parent component of Forea..." | Re-trigger Greptile

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented May 5, 2026

Merging this PR will degrade performance by 46.34%

⚠️ 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

❌ 3 regressed benchmarks
✅ 14 untouched benchmarks
⏩ 2 skipped benchmarks1

⚠️ Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
test_compile_page_full_context[_stateful_page] 15.9 ms 27.5 ms -42.03%
test_compile_page_single_pass[_stateful_page] 13.3 ms 24.7 ms -46.34%
test_compile_single_pass_all_artifacts[_stateful_page] 13 ms 23 ms -43.44%

Comparing masenf/do-not-memo-foreach-parent (ea54160) with main (a84e29b)

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.

Copy link
Copy Markdown
Contributor

@FarhanAliRaza FarhanAliRaza left a comment

Choose a reason for hiding this comment

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

We might want to add a full test.

def test_foreach_parent_does_not_absorb_sibling_into_snapshot() -> None:                                                                      
      """Foreach owns its own snapshot — its parent stays passthrough.                                                                          
                                                                                                                                                
      Regression for the foreach-parent memoization fix: previously a parent                                                                    
      component holding a Foreach was promoted to SNAPSHOT, and any sibling                                                                     
      reactive content under that parent was absorbed into a single wide memo                                                                   
      body. The parent should now render on the page side, with the Foreach                                                                     
      and any reactive sibling each getting their own independent wrapper.                                                                      
      """                                                                                                                                       
      from reflex_components_core.core.foreach import Foreach                                                                                   
                                                                                                                                                
      ctx, _page_ctx = _compile_single_page(                                                                                                    
          lambda: rx.box(                                                                                                                       
              Bare.create(SpecialFormMemoState.items.length()),  # reactive sibling                                                             
              rx.foreach(                                                                                                                       
                  SpecialFormMemoState.items,
                  lambda item: rx.text(item),                                                                                                   
              ),                                                             
          )
      )

      wrapped_types = {                                                                                                                         
          type(defn.component)
          for defn in ctx.auto_memo_components.values()                                                                                         
          if isinstance(defn, ExperimentalMemoComponentDefinition)                                                                              
      }
                                                                                                                                                
      # Parent rx.box is NOT memoized — it renders directly on the page side.                                                                   
      from reflex_components_core.radix.themes.layout.box import Box                                                                            
      assert Box not in wrapped_types                                                                                                           
                                                                             
      # Foreach owns its own SNAPSHOT memo.                                                                                                     
      assert Foreach in wrapped_types                                        
      foreach_defn = next(                                                                                                                      
          defn
          for defn in ctx.auto_memo_components.values()                                                                                         
          if isinstance(defn, ExperimentalMemoComponentDefinition)           
          and isinstance(defn.component, Foreach)                                                                                               
      )
      assert get_memoization_strategy(foreach_defn.component) is MemoizationStrategy.SNAPSHOT                                                   
                                                                                                                                                
      # Sibling Bare gets its own PASSTHROUGH memo, distinct from the foreach                                                                   
      # wrapper — it is not absorbed into the foreach-parent snapshot.                                                                          
      bare_defn = next(                                                                                                                         
          defn                                                               
          for defn in ctx.auto_memo_components.values()                                                                                         
          if isinstance(defn, ExperimentalMemoComponentDefinition)           
          and isinstance(defn.component, Bare)                                                                                                  
      )
      assert get_memoization_strategy(bare_defn.component) is MemoizationStrategy.PASSTHROUGH                                                   
      assert bare_defn is not foreach_defn                                                                                                      

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.

2 participants