Skip to content

perf(events): share one chain per handler and trigger across call sites - #7122

Open
FarhanAliRaza wants to merge 2 commits into
reflex-dev:mainfrom
FarhanAliRaza:farhan/event-chain-interning
Open

perf(events): share one chain per handler and trigger across call sites#7122
FarhanAliRaza wants to merge 2 commits into
reflex-dev:mainfrom
FarhanAliRaza:farhan/event-chain-interning

Conversation

@FarhanAliRaza

@FarhanAliRaza FarhanAliRaza commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Second of three stacked compile-performance PRs. Stacked on #7121; this diff includes it. Merge #7121 first.

  • EventChain.create interns one chain per handler, args spec, and trigger key on the handler object (handler.__dict__["_bound_chains"], alongside the existing __parameters slot). A handler bound to one trigger always produces the same chain, so every call site sharing the handler now shares one instance. Handlers with event_chain_kwargs, handlers carrying event actions (which are distinct dataclasses.replace copies), lists, lambdas, and EventSpec values are unaffected.
  • get_memoized_event_triggers keys its wrapper cache by trigger name and chain identity, holding the chain so its id cannot be recycled. The memo name stays content-hashed, so generated output is unchanged.

Why

The memoize pass rendered a fresh LiteralEventChainVar for every component to name its useCallback wrapper: ~240 µs per chain (LiteralVar.create + str + _get_all_var_data). With a hundred buttons bound to one handler that was the single largest item in the profile.

Measurements

Benchmark page with 100 rows bound to one handler (_repeated_stateful_page, full-context compile): 87.8 ms to 60.7 ms together with #7121, of which most is this change.

Docs site dry compile (511 routes, few handlers): 40.0 s (#7121) to 39.3 to 39.6 s. EventChain.create calls fell from 11.1k to 4.8k.

Design note for review

Call sites that bind the same handler to the same trigger now share one EventChain object. Chains are frozen dataclasses and nothing in the framework mutates events or event_actions in place (all action helpers return replaced copies), but this is a visible change in object identity.

Test plan

  • New tests: chain interning by handler, args spec, key, kwargs, and event actions; wrapper reuse by chain identity without rendering; distinct wrappers for chains that differ in captured args or actions.
  • tests/units/test_event.py, tests/units/reflex_base, tests/units/components, memoize plugin tests green apart from failures that reproduce on clean main here.
  • ruff, pyright, pre-commit clean.

https://claude.ai/code/session_01PmizE1eQhtYZyVs1RK2ke3

Review in cubic

Component render, Var collection, and the prop-component scan walked every
declared prop through the field descriptor to find the few that are set.
Iterate the instance dict plus class-level defaults instead. Cache the
literal Var class per exact value type, short-circuit app-wrap dedupe on
identity, skip the generic tag protocol for plain tags, and hoist the
memoize plugin's component imports.

Docs site dry compile (511 pages): 47 s to 40 s.

Claude-Session: https://claude.ai/code/session_01PmizE1eQhtYZyVs1RK2ke3
EventChain.create rebuilt an identical chain for every component that bound
the same handler to the same trigger, and the memoize pass then rendered
each chain again to name its useCallback wrapper. Intern the chain on the
handler keyed by args spec and trigger, and key the wrapper cache by chain
identity so repeated call sites reuse the wrapper without rendering.

Claude-Session: https://claude.ai/code/session_01PmizE1eQhtYZyVs1RK2ke3
@FarhanAliRaza
FarhanAliRaza requested a review from a team as a code owner September 11, 2026 23:14
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 11, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-11T23:20:30.541934Z c910356 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@greptile-apps

greptile-apps Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 4/5

The implementation appears behaviorally safe, but the repository’s changelog requirement must be satisfied before merging.

Findings

  1. P2 Root News Fragment Missing

Summary

  • Reuses one event chain for equivalent bindings of the same handler, argument specification, and trigger.
  • Reuses prepared useCallback wrappers within a registration context.
  • Avoids reading unset component props and streamlines exact-Tag rendering.
  • Adds type-based literal-Var dispatch caching and lighter cached-property identities.
  • Adds focused regression tests for sharing boundaries, metadata isolation, rendering equivalence, and cache lifecycle.

Reviews (1) · Last reviewed commit: "perf(events): share one chain per handle..."

Comment on lines +38 to +40
from reflex_components_core.base.bare import Bare
from reflex_components_core.core.cond import Cond
from reflex_components_core.core.match import Match

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Root News Fragment Missing

This changes production code in the root reflex package, but the PR only adds news fragments under packages/reflex-base/news. The repository requires a news fragment for every package whose source is changed. Add a root news/ performance fragment or apply the documented skip-changelog waiver before merging.

Context Used: CLAUDE.md (source)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c9103569b3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +38 to +40
from reflex_components_core.base.bare import Bare
from reflex_components_core.core.cond import Cond
from reflex_components_core.core.match import Match

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Add the required root Reflex news fragment

This commit changes packaged source under reflex/, but both new fragments are under packages/reflex-base/news/. On a PR without the skip-changelog label, the changelog workflow treats reflex and reflex-base as affected packages and the root-package check will fail because news/ has no fragment for this change. Add a corresponding root news/+<slug>.performance.md fragment.

AGENTS.md reference: AGENTS.md:L94-L100

Useful? React with 👍 / 👎.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

4 issues found across 14 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="reflex/compiler/plugins/memoize.py">

<violation number="1" location="reflex/compiler/plugins/memoize.py:38">
P2: Add a root `news/+<slug>.performance.md` fragment for the changed `reflex` package, or apply the documented `skip-changelog` waiver; otherwise the changelog check fails.</violation>
</file>

<file name="packages/reflex-base/src/reflex_base/registry.py">

<violation number="1" location="packages/reflex-base/src/reflex_base/registry.py:73">
P2: The new `_memoized_event_triggers` cache grows unboundedly over the lifetime of the process-wide RegistrationContext and is never invalidated. Each compile/recompile memoizes chains under fresh `id(event)` keys and holds strong references to the chain and its memo Var; nothing clears the dict (`fork()` omits it and `clear_hash_caches()` doesn't touch it), so dev-server reloads accumulate stale entries and retained VarData/memo vars across the app lifetime. Add an invalidation point (e.g. clear it in the same place `clear_hash_caches()` runs on compile, or on `fork()`), or bound entries that no longer match the live chain.</violation>
</file>

<file name="packages/reflex-base/src/reflex_base/vars/base.py">

<violation number="1" location="packages/reflex-base/src/reflex_base/vars/base.py:132">
P2: When an ABC-backed `python_types` registration changes after the first lookup, this returns a stale literal class for that concrete type. Avoid caching checks whose `isinstance` result can change, or provide invalidation for ABC/protocol registrations.</violation>

<violation number="2" location="packages/reflex-base/src/reflex_base/vars/base.py:145">
P2: Each new concrete value type is retained permanently by this global cache, including types with no literal handler. Use weak type keys or another bounded/lifecycle-aware cache so dynamically generated classes do not accumulate during a long-lived compile process.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

@@ -35,6 +35,9 @@
from reflex_base.constants.compiler import MemoizationDisposition

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Add a root news/+<slug>.performance.md fragment for the changed reflex package, or apply the documented skip-changelog waiver; otherwise the changelog check fails.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At reflex/compiler/plugins/memoize.py, line 38:

<comment>Add a root `news/+<slug>.performance.md` fragment for the changed `reflex` package, or apply the documented `skip-changelog` waiver; otherwise the changelog check fails.</comment>

<file context>
@@ -35,6 +35,9 @@
 from reflex_base.constants.compiler import MemoizationDisposition
 from reflex_base.plugins import ComponentAndChildren, PageContext
 from reflex_base.plugins.base import Plugin
+from reflex_components_core.base.bare import Bare
+from reflex_components_core.core.cond import Cond
+from reflex_components_core.core.match import Match
</file context>

repr=False,
)
_app: App | None = dataclasses.field(default=None, repr=False)
_memoized_event_triggers: dict[tuple[str, int], tuple[Any, Var]] = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: The new _memoized_event_triggers cache grows unboundedly over the lifetime of the process-wide RegistrationContext and is never invalidated. Each compile/recompile memoizes chains under fresh id(event) keys and holds strong references to the chain and its memo Var; nothing clears the dict (fork() omits it and clear_hash_caches() doesn't touch it), so dev-server reloads accumulate stale entries and retained VarData/memo vars across the app lifetime. Add an invalidation point (e.g. clear it in the same place clear_hash_caches() runs on compile, or on fork()), or bound entries that no longer match the live chain.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/reflex-base/src/reflex_base/registry.py, line 73:

<comment>The new `_memoized_event_triggers` cache grows unboundedly over the lifetime of the process-wide RegistrationContext and is never invalidated. Each compile/recompile memoizes chains under fresh `id(event)` keys and holds strong references to the chain and its memo Var; nothing clears the dict (`fork()` omits it and `clear_hash_caches()` doesn't touch it), so dev-server reloads accumulate stale entries and retained VarData/memo vars across the app lifetime. Add an invalidation point (e.g. clear it in the same place `clear_hash_caches()` runs on compile, or on `fork()`), or bound entries that no longer match the live chain.</comment>

<file context>
@@ -69,6 +70,9 @@ class RegistrationContext(BaseContext):
         repr=False,
     )
     _app: App | None = dataclasses.field(default=None, repr=False)
+    _memoized_event_triggers: dict[tuple[str, int], tuple[Any, Var]] = (
+        dataclasses.field(default_factory=dict, repr=False)
+    )
</file context>

"""
value_type = type(value)
try:
return _literal_var_by_type[value_type]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: When an ABC-backed python_types registration changes after the first lookup, this returns a stale literal class for that concrete type. Avoid caching checks whose isinstance result can change, or provide invalidation for ABC/protocol registrations.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/reflex-base/src/reflex_base/vars/base.py, line 132:

<comment>When an ABC-backed `python_types` registration changes after the first lookup, this returns a stale literal class for that concrete type. Avoid caching checks whose `isinstance` result can change, or provide invalidation for ABC/protocol registrations.</comment>

<file context>
@@ -114,6 +113,37 @@ class VarSubclassEntry:
+    """
+    value_type = type(value)
+    try:
+        return _literal_var_by_type[value_type]
+    except KeyError:
+        pass
</file context>

)
# A class object's type is its metaclass, which other classes share.
if not isinstance(value, type):
_literal_var_by_type[value_type] = literal_subclass

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Each new concrete value type is retained permanently by this global cache, including types with no literal handler. Use weak type keys or another bounded/lifecycle-aware cache so dynamically generated classes do not accumulate during a long-lived compile process.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/reflex-base/src/reflex_base/vars/base.py, line 145:

<comment>Each new concrete value type is retained permanently by this global cache, including types with no literal handler. Use weak type keys or another bounded/lifecycle-aware cache so dynamically generated classes do not accumulate during a long-lived compile process.</comment>

<file context>
@@ -114,6 +113,37 @@ class VarSubclassEntry:
+    )
+    # A class object's type is its metaclass, which other classes share.
+    if not isinstance(value, type):
+        _literal_var_by_type[value_type] = literal_subclass
+    return literal_subclass
 
</file context>

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.

1 participant