Skip to content

feat(vars): add get_leaf_vars - #7606

Merged
masenf merged 6 commits into
mainfrom
claude/get-leaf-vars
Oct 20, 2026
Merged

masenf merged 6 commits into
mainfrom
claude/get-leaf-vars

Conversation

@masenf

@masenf masenf commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

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

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

Description

Adds reflex_base.vars.get_leaf_vars(var) -> Sequence[Var], which returns the vars a var reads, deduplicated in order of first appearance. Each leaf is the object the compound var was built from, so callers can rely on identity.

A var operation such as State.items.filter(...) or State.items[1:] renders to a fresh value every time it is evaluated, so a React hook that reads one cannot list it as a dependency without recomputing on every render. With this helper such a hook can depend on State.items instead:

>>> [str(v) for v in get_leaf_vars((State.a + 1) * State.b)]
['state.a', 'state.b']
>>> [str(v) for v in get_leaf_vars(State.items.filter(lambda item: item == loop_var))]
['state.items', 'loop_var']
>>> [str(v) for v in get_leaf_vars(LiteralVar.create([[memo_prop], f"{State.name}"]))]
['memo_prop', 'state.name']

How a var is taken apart:

  • A CustomVarOperation reads its operands; every other cached operation (an array slice, an object item, a string concatenation, a function call) reads what its own fields hold. A raw value among them, such as a call argument or a container item, is converted through LiteralVar.create the way the renderer does, so an f-string with a var inside a list still reads that var.
  • A cast reads what it wraps, so a cast around a leaf is the leaf, as the caller holds it: State.x comes back as State.x. A cast around an operation or a function is seen through.
  • A literal dissolves: a scalar, Decimal, range or datetime reads nothing, a tuple, list, set or mapping reads what it holds, a color reads its parts, and any other value (an event, a component) stays whole as a leaf, since what it reads is not known.
  • A function reads its body minus the names its parameters bind, so a predicate closing over nothing yields no leaf and one closing over a loop var yields that var. It stays whole when a free leaf of its body still mentions a parameter, such as a var built from f"{item}.length" or a nested function closing over item, since hoisting that leaf out would name something bound only inside the function.
  • A state field access is a leaf.

A test pins how every CachedVarOperation subclass in reflex_base and reflex is sorted, so a new one has to be placed on purpose rather than falling through unnoticed.

The first use is the data editor rework in #7607, which is stacked on this PR and builds the dependency arrays of its useMemo and useCallback hooks from it.

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?

Tests in tests/units/reflex_base/vars/test_base.py assert leaf identity and cover a leaf returned alone, literals dissolving (scalars, containers, nested f-strings, colors, an event kept whole), nested operations and casts with deduplication, the operations with their own fields, raw call arguments, function bodies (closures, nested lambdas, rest parameters, a color body, a cast around a function, a body leaf reading a parameter keeping the function whole), and the sorting of every cached operation class. ruff, pyright on the touched files, and pytest tests/units/reflex_base/vars/test_base.py pass on 3.14 and in a separate 3.11 environment.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RqJ6TNt8AXzbDVm5kLb2nD

A var operation such as `State.items.filter(...)` renders to a fresh value
every time it is evaluated, so a React hook reading one cannot list it as a
dependency without recomputing on every render. `get_leaf_vars` takes a var
apart into the leaf vars it is built from: a `CustomVarOperation` yields its
operands and a cast yields the var it wraps, recursively, deduplicated in
order of first appearance. Any other var, a literal or function included, is
a leaf and comes back alone. It is the one place to extend when other
containers need unwrapping.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqJ6TNt8AXzbDVm5kLb2nD
@greptile-apps

greptile-apps Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] No new blocking issue was established in the changes since the previous review.

Summary

Adds get_leaf_vars to collect the inputs behind derived vars for React hook dependencies.

  • Callers can find the vars a derived expression reads.

Reviews (6) · Last reviewed commit: "feat(vars): keep a function whole when a..." · Reviewed by Greptile

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

All reported issues were addressed across 4 files

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

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread packages/reflex-base/src/reflex_base/vars/base.py Outdated
@codspeed

codspeed Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

⚠️ 14 benchmarks spent significant time in system calls

System calls cannot be consistently instrumented, so they are not included in the measure, which understates the real cost. Please switch to the Walltime instrument to accurately measure system calls.

Measurement and system calls

✅ 148 untouched benchmarks


Comparing claude/get-leaf-vars (2c6c165) with main (4be8b4d)

Open in CodSpeed

`get_leaf_vars` only unwrapped `CustomVarOperation` and casts, so an array
slice, an object item, a string concatenation or a function call stayed a
leaf and a hook depending on it recomputed on every render. It also kept a
literal container whole, so a memo prop or loop var inside `[[value]]` was
invisible, and kept a lambda whole, so one closing over a loop var could not
be depended on without listing the whole function.

Unwrap every cached operation through the vars held in its fields, with
`CustomVarOperation` and casts handled as before. Dissolve a literal: a
scalar reads nothing, a tuple, list or mapping reads the vars it holds, and
any other value such as an event, a color or a component stays whole, since
what it reads is not known. Read a function through its body minus the
names its parameters bind, so a predicate closing over nothing yields no
leaf and one closing over a loop var yields that var.

A test pins how every cached operation class is sorted, so a new one has to
be placed on purpose.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqJ6TNt8AXzbDVm5kLb2nD

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

All reported issues were addressed across 3 files (changes from recent commits).

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

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread packages/reflex-base/src/reflex_base/vars/base.py Outdated
Comment thread packages/reflex-base/src/reflex_base/vars/base.py Outdated
Comment thread packages/reflex-base/src/reflex_base/vars/base.py Outdated
…whole

`get_leaf_vars` skipped a raw value held by an operation or a literal
container, although the renderer passes it through `LiteralVar.create`: an
f-string with a var inside a list, or a color passed as a call argument, read
a var that was not reported. Convert every raw operand the same way before
taking it apart, so those reads are found.

A color literal now reads its parts instead of staying whole, and ranges and
datetimes count as constants, since they render the same every time.

A literal that stays whole inside a lambda body, such as an event, may read
the lambda's parameters without its expression equalling a parameter name,
so hoisting it out of the function could reference a name that only exists
inside the callback. Such a function now stays whole as a leaf.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqJ6TNt8AXzbDVm5kLb2nD
`test_get_leaf_vars_sorts_every_cached_operation` failed on Python 3.11
and 3.12: a `slots=True` dataclass is a rebuilt copy of the class first
defined, and the discarded original stays in `__subclasses__` until it is
garbage collected, so the set held two objects per class and set equality
failed. Compare qualified names instead, which collapse the duplicates on
every version.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqJ6TNt8AXzbDVm5kLb2nD
Comment thread tests/units/reflex_base/vars/test_base.py Outdated
The leaf tests compared rendered strings, which pass even when a leaf is a
fresh object missing its var data. Assert object identity instead: every
leaf is the var the compound var was built from. The one case that
constructs a new object, a var that is the whole of a formatted string, is
checked with `Var.equals`, which compares the var data too.

For that contract to hold, a cast around a leaf now comes back as the cast
the caller holds, such as `State.x` or `Var(...).to(int)`, rather than the
object inside it. A cast around an operation is still seen through to the
operation's operands.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqJ6TNt8AXzbDVm5kLb2nD
@masenf masenf added the on deck PRs lined up to review / merge next label Oct 20, 2026
Comment thread packages/reflex-base/src/reflex_base/vars/base.py Outdated
Comment thread packages/reflex-base/src/reflex_base/vars/base.py
Comment thread packages/reflex-base/src/reflex_base/vars/base.py Outdated
Comment thread packages/reflex-base/src/reflex_base/vars/base.py
Comment thread packages/reflex-base/src/reflex_base/vars/base.py Outdated
Comment thread tests/units/reflex_base/vars/test_base.py Outdated
…ameter

A body leaf that is not a parameter itself may still read one: a var built
from a formatted parameter such as `Var(f"{item}.length")`, or a nested
function closing over the outer parameter. Hoisting it out of the function
named something bound only inside, which crashed a hook with a
`ReferenceError`. A function now stays whole when any free leaf of its body
mentions a bound name, which also replaces the narrower check on literals
kept whole.

Fold the cast and function handling into `_operands`, so `get_leaf_vars`
only pushes operands or records a leaf and every helper stays under the
complexity limit. A cast reads what it wraps, so a cast around a function
now reads like the function instead of staying whole.

Count `Decimal` as a constant and `set` and `frozenset` as containers, type
the parameter helper's argument, and pin the operation classes defined in
`reflex` in the sorting test as well.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqJ6TNt8AXzbDVm5kLb2nD
@masenf
masenf merged commit 4c554f6 into main Oct 20, 2026
169 checks passed
@masenf
masenf deleted the claude/get-leaf-vars branch October 20, 2026 22:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

on deck PRs lined up to review / merge next

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants