feat(vars): add get_leaf_vars

Merged
MA
8h ago
+413-5

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