Warn when an unkeyed rx.foreach item has lifecycle events or hooks

Merged
MA
11h ago
+176-1

Found in the reflex 0.11.0a1 pre-release testing (FINDING-039).

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)
  • This change requires a documentation update

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?

Description

rx.foreach keys an item without an explicit key by its index (IterTag.render_component). Since #7793, each item runs its own hooks, but with index keys those hooks follow the position, not the item. Take rx.foreach(S.items, lambda item: rx.text(item, on_mount=S.record_mount(item), on_unmount=S.record_unmount(item))) with items [alpha, beta, gamma]: removing alpha fires record_unmount("gamma"). key=item fixes it for a list of unique strings. It is not a safe default, because object items stringify to duplicate keys and duplicate primitives collide.

This PR adds a warning and docs:

  • MemoizeStatefulPlugin already finds each foreach item subtree with hooks of one rendered instance (lifecycle events, _get_hooks, add_hooks, components held in props), because it gives such items their own wrapper inside the loop (#7793). When that scoping changes a Foreach and the item's key is the loop index, the plugin logs a warning. That covers both a missing key and an explicit key=index. The warning names the state fields the iterable reads and the page route (for example over `items` on page `index` ), and suggests key=item.id, or key=item for a list of unique strings. A constant literal list never reorders, so it does not warn; a literal list that holds a state var does. The record sets extra={"dedupe": True}, which collapses identical warnings within one process. Each hot reload starts a new worker, so the warning repeats once per reload until the key is fixed.
  • The check costs nothing beyond the scoping the plugin already does, and it runs only during a frontend compile. Evaluating pages (backend workers, reflex db) is untouched. An earlier revision checked every unkeyed item in IterTag.render_component, which made page evaluation about 20% slower on the stateful benchmark page. The current revision times the same as main.
  • docs/library/dynamic-rendering/foreach.md has a new "Keys" section. It explains index keying, shows the on_unmount symptom, mentions per-row state, and says to set key= to a unique, stable value.

Related but out of scope: FINDING-040 (hoisted lifecycle hooks of a cond inside @rx.memo) and FINDING-041 (a foreach with item hooks inside @rx.memo crashes the page). Hooks inside an @rx.memo row are not scoped per item yet, so they don't warn yet either.

Tests

In tests/units/compiler/test_memoize_plugin.py, each test compiles a page:

  • test_foreach_item_with_hooks_keyed_by_index_warns covers on_mount + on_unmount, a descendant with on_mount, on_unmount alone, add_hooks, an explicit key=index, and an inner loop of a keyed row. Each warns once, names the field and page, and the record opts into deduplication.
  • test_foreach_item_keyed_or_without_hooks_does_not_warn covers key=item, an item without hooks, and an item with only on_click.
  • test_foreach_over_literal_list_warns_only_with_state checks that a constant list does not warn and a literal list holding a state var does.
  • test_evaluating_a_foreach_item_with_hooks_does_not_warn checks that evaluating the page without compiling logs nothing.

uv run pytest tests/units passed on the first revision (15643 passed; tests/units/reflex_bench/drivers/test_browser.py was skipped because Chromium is not installed here). ruff check, ruff format and pyright on the changed files are clean. After the follow-up commit, tests/units/compiler/test_memoize_plugin.py passes (166 passed).


Generated by Claude Code