Skip to content

Fix stale keyed x-for range scopes#4851

Open
joshhanley wants to merge 3 commits into
mainfrom
josh/fix-x-for-stale-scope
Open

Fix stale keyed x-for range scopes#4851
joshhanley wants to merge 3 commits into
mainfrom
josh/fix-x-for-stale-scope

Conversation

@joshhanley

Copy link
Copy Markdown
Collaborator

The Scenario

A keyed x-for range 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_start changes as the user scrolls:

<template x-for="i in visible_end - visible_start" :key="visible_start + i">
    <span x-text="visible_start + i"></span>
</template>

The Problem

When visible_start changes, Alpine may reuse an existing keyed element for a different range item. Directives inside that element, such as the x-text on the span above, can run from the outer visible_start update before x-for refreshes the reused element's loop scope.

For example, after scrolling, an element that previously represented i = 13 might be reused for the new key where i should now be 1.

At that point, the parent scope has already updated visible_start to 12, but the reused loop scope still contains the old i = 13. If the x-text effect runs in that moment, it evaluates visible_start + i as 12 + 13, rendering 25 instead of 13.

x-for then refreshes the reused element's loop scope from i = 13 to i = 1. Updating i should schedule the x-text effect again so it can re-evaluate visible_start + i and render 13.

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 i update tried to schedule the x-text job 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-text expression 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-for case, this allows the x-text effect to run once with the mixed old/new state, then run again after x-for refreshes the reused loop scope. The final render uses the updated outer value and the updated i value.

Fixes #4843

@joshhanley joshhanley changed the title Fix stale keyed x-for range scopes Fix stale keyed x-for range scopes Jul 1, 2026
@ekwoka

ekwoka commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

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.

I'm pretty sure the intent there is to prevent circular dependency changes retriggering endlessly.

@calebporzio

Copy link
Copy Markdown
Collaborator

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 (includes(job, flushIndex + 1)).

@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 lastFlushedIndex only advances after a job returns, so it's specifically multi-effect cycles. Vue accepts this in prod and only guards in dev with a recursion-count warning.

Two thoughts before merge: (1) a short comment on the includes fromIndex would help — it reads like a typo to anyone who forgets includes takes a second arg, and (2) we should decide whether to add a Vue-style per-job run cap in flushJobs so a circular dependency degrades to a console warning instead of a freeze.

@joshhanley

Copy link
Copy Markdown
Collaborator Author

@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 x-for case while stopping circular updates from happening.

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 lastFlushedIndex + 1) and has the added benefit of using set lookups, which are faster than the array lookups we previously had.

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.

Keyed x-for with seems to not be robust against adding/removing elements

3 participants