Fix stale keyed x-for range scopes#4851
Conversation
x-for range scopes
I'm pretty sure the intent there is to prevent circular dependency changes retriggering endlessly. |
|
Verified this locally — the new Cypress test fails on main's scheduler and passes with the fix, and the approach matches Vue's scheduler dedupe ( @ekwoka's point checks out though: two effects that mutually re-trigger each other used to settle after one run each per flush, and with this change they re-queue forever (confirmed with a direct scheduler simulation — frozen tab). Self-retriggering jobs are still deduped since Two thoughts before merge: (1) a short comment on the |
|
@ekwoka thanks, yeah good point. I confirmed the circular case with two effects that trigger each other and added a regression test for it. @calebporzio I considered Vue’s recursion cap, but chose not to mirror it here. Vue’s limit is a development-only diagnostic that permits roughly 100 recursive runs, while production does not enforce the limit. Alpine previously deduplicated jobs across the whole flush, preventing any job from being requeued after it had run. This fix only requires one additional run. A job that has already run can become stale when its data changes later in the same flush, so the scheduler now allows it to be added back once and rejects further repeats. This fixes the While I was implementing the requeueing limit, I found it was simpler to split the queue into pending and completed sets. This makes it easier to read (no |
The Scenario
A keyed
x-forrange can render stale values when the key combines an outer reactive value with the range item.For example, this pattern is useful for virtualised lists where
visible_startchanges as the user scrolls:The Problem
When
visible_startchanges, Alpine may reuse an existing keyed element for a different range item. Directives inside that element, such as thex-texton thespanabove, can run from the outervisible_startupdate beforex-forrefreshes the reused element's loop scope.For example, after scrolling, an element that previously represented
i = 13might be reused for the new key whereishould now be1.At that point, the parent scope has already updated
visible_startto12, but the reused loop scope still contains the oldi = 13. If thex-texteffect runs in that moment, it evaluatesvisible_start + ias12 + 13, rendering25instead of13.x-forthen refreshes the reused element's loop scope fromi = 13toi = 1. Updatingishould schedule thex-texteffect again so it can re-evaluatevisible_start + iand render13.The scheduler blocked that second run. It kept all jobs in the queue array until the current flush completed, including jobs that had already run. When the
iupdate tried to schedule thex-textjob again, the scheduler found the existing copy of that job in the queue and treated it as already pending.At that point the job was not actually pending. It had already run with the mixed old/new state. Because it was not added back to the queue, the corrected loop scope never caused the
x-textexpression to run again during that flush.The Solution
The solution is to change scheduler deduping so it only checks jobs that have not run yet.
Before this change, the scheduler checked the whole queue when deciding whether to add a job. That prevented duplicate pending work, but it also treated already-run jobs as if they were still pending because they remained in the queue array until the flush completed.
Now the scheduler starts its duplicate check after
lastFlushedIndex. Jobs that are still waiting to run are deduped as before. Jobs that already ran earlier in the same flush can be added again if another reactive update needs them to run with newer state.In the keyed
x-forcase, this allows thex-texteffect to run once with the mixed old/new state, then run again afterx-forrefreshes the reused loop scope. The final render uses the updated outer value and the updatedivalue.Fixes #4843