Warn when an unkeyed rx.foreach item has lifecycle events or hooks
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:
MemoizeStatefulPluginalready 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 explicitkey=index. The warning names the state fields the iterable reads and the page route (for exampleover `items` on page `index`), and suggestskey=item.id, orkey=itemfor 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 setsextra={"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 inIterTag.render_component, which made page evaluation about 20% slower on the stateful benchmark page. The current revision times the same asmain. docs/library/dynamic-rendering/foreach.mdhas a new "Keys" section. It explains index keying, shows theon_unmountsymptom, mentions per-row state, and says to setkey=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_warnscoverson_mount+on_unmount, a descendant withon_mount,on_unmountalone,add_hooks, an explicitkey=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_warncoverskey=item, an item without hooks, and an item with onlyon_click.test_foreach_over_literal_list_warns_only_with_statechecks 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_warnchecks 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