Skip to content

fix: Scan the lifecycle queue without its iterator in dequeueAdd/dequeueRemove - #4021

Merged
spydon merged 1 commit into
mainfrom
fix/lifecycle-queue-nested-iteration
Aug 27, 2026
Merged

fix: Scan the lifecycle queue without its iterator in dequeueAdd/dequeueRemove#4021
spydon merged 1 commit into
mainfrom
fix/lifecycle-queue-nested-iteration

Conversation

@spydon

@spydon spydon commented Aug 26, 2026

Copy link
Copy Markdown
Member

Description

RecycledQueue only supports a single iterator at a time: its iterator getter resets the cursor and runs garbage collection. ComponentTreeRoot.dequeueAdd and dequeueRemove scanned the queue with a plain for (final event in queue), and both are reachable from inside processLifecycleEvents:

  • Component._removeChild calls dequeueAdd when removing a component that is queued but not yet mounted.
  • Component._addChild calls dequeueRemove when re-adding a component that is currently removing.

When user code did either of those from onMount (or onLoad), the nested iteration clobbered the outer cursor. In the dequeueAdd case the outer loop then called removeCurrent() on the wrong element, so the already processed add event survived in the queue, was handled a second time and tripped assert(!isMounted) in handleLifecycleEventAdd. In the dequeueRemove case the nested scan ran to the end and the outer removeCurrent() hit Cannot remove current element if not iterating.

dequeueAdd, dequeueRemove, handleResize and handleHotReload now go through RecycledQueue.forEachWhere, which walks the backing storage directly and is safe to call during iteration (it is what cancelQueuedRemoves already used for the same reason). Behavior is unchanged: dequeueAdd still cancels only the first matching event and still throws if none is found, dequeueRemove still cancels all matching events.

Two regression tests reproduce both crashes deterministically: one mounts a component whose onMount removes a queued sibling, the other mounts a component whose onMount re-adds a component that is being removed in the same tick.

Checklist

  • I have followed the Contributor Guide when preparing my PR.
  • I have updated/added tests for ALL new/updated/fixed functionality.
  • I have updated/added relevant documentation in docs and added dartdoc comments with ///.
  • [-] I have updated/added relevant examples in examples or docs.

Breaking Change?

  • Yes, this PR is a breaking change.
  • No, this PR is not a breaking change.

Related Issues

Closes #4018

…eueRemove

RecycledQueue only supports a single iterator at a time, but dequeueAdd
and dequeueRemove iterated the queue with a plain for loop. Both are
reachable from inside processLifecycleEvents (through onMount/onLoad
callbacks that remove a queued sibling or re-add a removing component),
so the nested iteration reset the outer cursor, an already processed add
event survived in the queue and was handled again, and the second mount
tripped assert(!isMounted).

Route dequeueAdd, dequeueRemove, handleResize and handleHotReload through
RecycledQueue.forEachWhere, which walks the storage directly and is safe
to call during iteration.

Fixes #4018

Claude-Session: https://claude.ai/code/session_01Hqw2qU499wSThwN3EwEDR4
@spydon
spydon merged commit b4578fc into main Aug 27, 2026
10 checks passed
@spydon
spydon deleted the fix/lifecycle-queue-nested-iteration branch August 27, 2026 13:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

!isMounted': is not true - dequeueAdd/dequeueRemove iterate the lifecycle queue while processLifecycleEvents is already iterating it

2 participants