Pass loop variables to auto-memo wrappers inside rx.foreach
Merged
FA
1d ago+617+617+012345678901234567890123456789-7-7-0123456789
Issues fixed
- Fixes #7791:
on_mountoron_unmountinsiderx.foreachthat uses the loop variable crashes the page withReferenceError: x_rx_state_ is not defined. - Fixes #3210: a form inside
rx.foreachcannot use the loop value or index in itson_submithandler (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
- A loop variable names itself in
VarData.deps.IterTagcreates the item and index vars withdeps=[<the var>]. Every var built from them carries the name.VarData.depsalready has this meaning: it fillsuseCallbackdependency lists. - Inside a loop, a component with hooks of its own gets an auto-memo wrapper. Before the memoize plugin seals a
Foreach,_scope_instance_hookswraps each descendant that has a lifecycle event,_get_hooks, oradd_hooks. A component that holds such a component in a prop is wrapped too. The wrapper is called in the loop body. - The wrapper receives the loop variables as props.
scope_var_namescollects the names fromVarData.depsof 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.depstoo. - 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
depsfrom this change, so code outside loops compiles as before.
Testing
- New unit tests in
tests/units/compiler/test_memoize_plugin.py:on_mount,add_hooksand index-variable items, a nested loop that reads the outer loop variable, a state-backed item key, a form whoseon_submitreads 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, withreact_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 itson_unmount. - An unrelated state update fires nothing, and an input inside an item keeps its text.
- No console errors.
- Each item fires its own
- 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
Foreachwrappers and the item wrappers compile, with no hook errors.
Known limits
- A loop inside a user
@rx.memofunction is not covered. The body of a user memo does not go through the memoize plugin, so a hook there is still hoisted, as onmain. - Render performance with many wrapped items is not measured.