Run the hooks of an rx.foreach item inside the loop body

Closed
FA
2d ago
+357-5

Fixes #7791

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.

Fix

When an item has such hooks, Foreach compiles the item to a component at module level, in the same generated file, and passes the loop variables as props:

const ForeachItem_<hash> = (({x_rx_state_, index}) => {
  useEffect(() => { ...seen({ x: x_rx_state_ }) }, []);
  return (jsx(Text, {key: index}, x_rx_state_))
});

items.map((x_rx_state_, index) => jsx(ForeachItem_<hash>, {key: index, x_rx_state_, index}))

The hooks belong to one item, and the loop variables are in scope. The item component also receives the variables that the enclosing generated code binds: the loop variables of enclosing rx.foreach loops and the parameters of an enclosing @rx.memo function. The hooks that the item key needs stay in the outer scope, because the loop body evaluates the key there.

An item gets its own component only when:

  • a component in it has a lifecycle event, _get_hooks, or add_hooks, or
  • one of its hooks reads the loop variable or the index variable.

All other loops compile to the same code as before.

The item component is a normal top-level function component, so React Compiler accepts it. A first version called the hooks in a function expression inside .map(); React Compiler rejects that ("Hooks must be called at the top level").

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 lifecycle event in a prop component, a loop inside a memo function, and an item without such hooks.
  • tests/units/compiler and tests/units/components/core: 520 passed. All unit tests that match memo or foreach: 434 passed.
  • Live check, reflex run --env prod, Chromium, default config:
    • 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.
  • React Compiler (the experimental release and plugin settings from #7694) on the generated memo module: the item components and the Foreach components compile, with no hook errors.

Known limits

  • The item component receives loop variables and memo parameters. A value that a raw hook string declares in the enclosing function, and that the item reads without that hook in its VarData, is not in scope in the item component.