Pass loop variables to auto-memo wrappers inside rx.foreach

Merged
FA
1d ago
+617-7

Issues fixed

  • Fixes #7791: on_mount or on_unmount inside rx.foreach that uses the loop variable crashes the page with ReferenceError: x_rx_state_ is not defined.
  • Fixes #3210: a form inside rx.foreach cannot use the loop value or index in its on_submit handler (ReferenceError: index is not defined).

Both have the same cause: a hook of a component inside the loop is compiled outside the loop, where the loop variables do not exist.

Replaces #7792. This version follows the approach suggested in review there: the auto-memo system carries scoped variables into the wrappers it creates, instead of Foreach keeping its own item component and its own list of names.

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

  • Bug fix (non-breaking change which fixes an issue)

Problem

A hook of a component inside rx.foreach was hoisted to the top of the Foreach memo body, outside the loop:

rx.foreach(S.items, lambda x: rx.text(x, on_mount=S.seen(x)))
const Foreach_comp_... = memo(({children}) => {
  useEffect(() => { ...seen({ x: x_rx_state_ }) }, []);   // x_rx_state_ does not exist here
  return items.map((x_rx_state_, index) => jsx(Text, {key: index}, x_rx_state_))
});
  • A hook that read the loop variable crashed the page: ReferenceError: x_rx_state_ is not defined.
  • A hook that did not read it ran once for the whole list, not once per item.

This applied to on_mount, on_unmount, and hooks from add_hooks. The rx.form submit handler is such a hook, which is the case in #3210.

Fix

  1. A loop variable names itself in VarData.deps. IterTag creates the item and index vars with deps=[<the var>]. Every var built from them carries the name. VarData.deps already has this meaning: it fills useCallback dependency lists.
  2. Inside a loop, a component with hooks of its own gets an auto-memo wrapper. Before the memoize plugin seals a Foreach, _scope_instance_hooks wraps each descendant that has a lifecycle event, _get_hooks, or add_hooks. A component that holds such a component in a prop is wrapped too. The wrapper is called in the loop body.
  3. The wrapper receives the loop variables as props. scope_var_names collects the names from VarData.deps of the wrapped component's vars. create_passthrough_component_memo(scope_vars=...) adds each one as a parameter.
const Text_text_<hash> = memo(({children, xRxState: x_rx_state_, ...rest}) => {
  useEffect(() => { ...seen({ x: x_rx_state_ }) }, []);
  return jsx(RadixThemesText, {...mergeSlotProps(rest, {as: "p"})}, children)
});

items.map((x_rx_state_, index) =>
  jsx(Text_text_<hash>, {key: index, xRxState: x_rx_state_}, x_rx_state_))
  • A wrapper in a nested loop also receives the loop variables of the outer loops, because their names are in VarData.deps too.
  • A loop that binds a name itself does not pass it in from outside.
  • An item without hooks of its own compiles to the same code as before.
  • No name outside a loop carries deps from this change, so code outside loops compiles as before.

Testing

  • New unit tests in tests/units/compiler/test_memoize_plugin.py: on_mount, add_hooks and index-variable items, a nested loop that reads the outer loop variable, a state-backed item key, a form whose on_submit reads the loop index (#3210), a lifecycle event in a prop component, a loop variable that only an added hook var reads, and an item without hooks of its own.
  • All unit tests that match memo or foreach or iter: 864 passed.
  • Live check, reflex run --env prod, Chromium, with react_compiler=True:
    • Each item fires its own on_mount, also in a nested loop.
    • A new item fires only its own on_mount. A removed item fires its on_unmount.
    • An unrelated state update fires nothing, and an input inside an item keeps its text.
    • No console errors.
  • Live check of the example from #3210, reflex run --env prod, default config: three forms render, and a submit of the third and the first form calls the handler with index 2 and index 0.
  • React Compiler 1.0.0 on the generated memo module: the Foreach wrappers and the item wrappers compile, with no hook errors.

Known limits

  • A loop inside a user @rx.memo function is not covered. The body of a user memo does not go through the memoize plugin, so a hook there is still hoisted, as on main.
  • Render performance with many wrapped items is not measured.