Skip to content

Guard move() against moving an element into its own descendant - #6180

Merged
falkoschindler merged 5 commits into
zauberzeug:mainfrom
evnchn:midnight/element-524
Jul 30, 2026
Merged

Guard move() against moving an element into its own descendant#6180
falkoschindler merged 5 commits into
zauberzeug:mainfrom
evnchn:midnight/element-524

Conversation

@evnchn

@evnchn evnchn commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Motivation

move() has no guard against moving a container into itself or one of its own descendants, which forms an a ↔ b containment cycle. Any later tree traversal (descendants(), str(), clear(), delete()) then recurses forever → RecursionError / hang.

Minimal reproduction (nicegui/element.py:524) — recursion capped first so it can't hang the machine:

import sys
sys.setrecursionlimit(200)          # cap FIRST, before the cycle exists

from nicegui import ui
a = ui.card()
with a:
    b = ui.card()
a.move(b)                            # move a container into its own descendant -> a<->b cycle

list(a.descendants())               # RecursionError (would hang uncapped)

RecursionError

Implementation

Reject a move into self or a descendant with a ValueError before any mutation (mirrors the existing target-slot validation), so the tree is left intact; legitimate moves are unaffected.

Verification
  • Regression test fails on origin/main (no error raised, cycle formed), passes with the fix; a legitimate x.move(p2) still works.
  • Local gates: pre-commit · mypy ./nicegui · pylint 10.00/10 · pytest (touched) — all green.

Progress

  • The PR title is a short phrase starting with a verb.
  • The implementation is complete.
  • This PR does not address a security issue.
  • Pytests have been added/updated.
  • Documentation is not necessary.
  • No breaking changes to the public API.

evnchn and others added 2 commits July 25, 2026 00:14
DateInput applied .props('no-parent-event') to the inner QDate, but
Quasar 2.18.5's QDate has no such prop (noParentEvent belongs to the
anchor-props mixin behind QMenu/QTooltip). It was an inert no-op
attribute; removing it changes no behavior (open/close is driven by the
parent QMenu).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Moving an element into itself or one of its own descendants left both
elements referencing each other, so descendants(), clear() and str()
would recurse infinitely. Reject such a move before mutating the tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@evnchn
evnchn marked this pull request as ready for review July 25, 2026 06:17
@evnchn evnchn added bug Type/scope: Incorrect behavior in existing functionality review Status: PR is open and needs review labels Jul 26, 2026
@falkoschindler falkoschindler self-assigned this Jul 26, 2026
@falkoschindler
falkoschindler self-requested a review July 26, 2026 20:36
@falkoschindler

Copy link
Copy Markdown
Contributor

@falkoschindler asked me to bring the open PRs up to date with main so the diffs are easier to read. This one couldn't be updated automatically — it conflicts.

The conflict is in nicegui/element.py and tests/test_element.py, against ff073f4 ("Fix move() orphaning an element when target_slot is invalid", #6176). Both changes rework the same part of move(), so resolving it is a judgement call about the combined logic rather than a mechanical merge — handing it back to you.

Assigning you to make the ownership visible.


Generated by Claude Code

evnchn and others added 2 commits July 29, 2026 12:48
Reconcile the cycle guard with zauberzeug#6176: `target_slot` is still resolved and
validated before the element is detached, and the descendant check now runs
ahead of that resolution, so neither an invalid slot nor a descendant target
can mutate the tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Without this, the test passes even when the descendant guard sits after the
detach — the exact mis-merge this branch had to avoid. Mirrors the wording of
the sibling check added in zauberzeug#6176.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@evnchn

evnchn commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

Posted by Claude Code on evnchn's behalf.

Resolved — merged main in (fa8c983b), now MERGEABLE with CI green.

How the two move() changes combined

You were right that this one was a judgement call rather than a mechanical merge. #6176 stops move() orphaning an element when target_slot is invalid; this PR rejects a move into the element's own descendant. They compose — both are pre-flight rejections, and neither may leave the element detached.

The ordering is the load-bearing part. The cycle guard has to run before target_container defaults to the current parent slot, and before any detach:

parent_slot = self.parent_slot
assert parent_slot is not None
if target_container is not None and self in target_container.ancestors(include_self=True):
    raise ValueError('Cannot move an element into itself or one of its descendants.')
target_container = target_container or parent_slot.parent

The naive merge — guard placed after the detach — still passes the original test, because that test only checked that ValueError was raised and that the subtree was intact. So 668df2ca adds the missing assertion, mirroring the sibling check #6176 introduced:

root = a.parent_slot
with pytest.raises(ValueError):
    a.move(b)
with pytest.raises(ValueError):
    a.move(a)
assert a in root.children, 'a rejected move must keep the element in its original slot'

Verified: quick-test / 3.10 / pytest, check / mypy, check / pre-commit, check / pylint all pass on 668df2ca.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@falkoschindler falkoschindler left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for catching this, Evan! The guard is correct and well-placed: it runs before any mutation, so a rejected move leaves the tree untouched, and checking the target's ancestors is O(depth) rather than scanning the whole subtree.

I pushed a small polish commit: the guard now sits below the defaulting line (the default target — the current parent — can never be self or a descendant, so the is not None check became unnecessary), the test pins the expected error with match= to distinguish it from the unknown-slot ValueError, and the test variables are named outer/inner/label/other to spell out the nesting.

@falkoschindler falkoschindler added this to the 3.16 milestone Jul 30, 2026
@falkoschindler
falkoschindler enabled auto-merge July 30, 2026 22:20
@falkoschindler
falkoschindler added this pull request to the merge queue Jul 30, 2026
Merged via the queue into zauberzeug:main with commit 4996609 Jul 30, 2026
7 checks passed
@evnchn
evnchn deleted the midnight/element-524 branch July 31, 2026 01:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Type/scope: Incorrect behavior in existing functionality review Status: PR is open and needs review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants