Skip to content

ENG-9661 fix: cancel stale on_load chains via handler-declared supersession#6713

Open
FarhanAliRaza wants to merge 5 commits into
reflex-dev:mainfrom
FarhanAliRaza:cancel-event
Open

ENG-9661 fix: cancel stale on_load chains via handler-declared supersession#6713
FarhanAliRaza wants to merge 5 commits into
reflex-dev:mainfrom
FarhanAliRaza:cancel-event

Conversation

@FarhanAliRaza

Copy link
Copy Markdown
Contributor

Navigating away while a page's on_load chain is still running left the stale chain executing, blocking the new page's events behind it and applying its late deltas (#6593).

Add a SUPERSEDES_MARKER for event handlers with latest-wins semantics: enqueuing a new chain-root invocation cancels the previous unfinished event chain rooted at the same handler for the same client token. Mark on_load_internal with it so a newer navigation supersedes the previous page's unfinished load.

The EventProcessor tracks the active chain root per (event name, token), drops events chained from an already-cancelled parent before they enter the queue, and defers future cleanup while the handler task is still unwinding so late-chained events can find their cancelled parent.

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

Please delete options that are not relevant.

  • New feature (non-breaking change which adds functionality)

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?

Navigating away while a page's on_load chain is still running left the
stale chain executing, blocking the new page's events behind it and
applying its late deltas (reflex-dev#6593).

Add a SUPERSEDES_MARKER for event handlers with latest-wins semantics:
enqueuing a new chain-root invocation cancels the previous unfinished
event chain rooted at the same handler for the same client token. Mark
on_load_internal with it so a newer navigation supersedes the previous
page's unfinished load.

The EventProcessor tracks the active chain root per (event name, token),
drops events chained from an already-cancelled parent before they enter
the queue, and defers future cleanup while the handler task is still
unwinding so late-chained events can find their cancelled parent.
@FarhanAliRaza
FarhanAliRaza requested a review from a team as a code owner July 6, 2026 22:31

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2db865caa9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +509 to +512
if future.txid in self._tasks:
# The handler task is still running or unwinding; keep the future
# so late-chained events can find their (possibly cancelled) parent.
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Don’t retain failed futures during exception recovery

When a state handler raises and BaseStateEventProcessor starts backend_exception_handler, _finish_task stores that exception-handler task in _tasks under the same txid after setting the original future's exception. This new guard keeps the already-done failed future in _futures, so if the exception handler returns a backend EventSpec as the public API allows, ctx.enqueue() finds that done future as the parent and add_child() raises instead of queuing the recovery event. The retention here is only needed for cancelled tasks unwinding, not for failed futures being handled by the backend exception handler.

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a long-standing navigation bug (#6593) where navigating away from a page while its on_load chain was still running left stale handlers executing — blocking the new page's events and applying their deltas out of order. The fix introduces a SUPERSEDES_MARKER / supersedes=True decorator option that gives a handler "latest-wins" semantics, and marks on_load_internal with it so a new navigation immediately cancels the previous page's unfinished load chain for the same client token.

  • New supersedes mechanism — adds SUPERSEDES_MARKER, an EventHandler.supersedes property, EventFuture.supersede_key, and EventProcessor._superseded tracking; root handlers marked superseding cancel the previous chain on enqueue.
  • Careful deferred cleanup_try_clean_future keeps a cancelled future alive in _futures while the handler task unwinds, so late-chained events can find their cancelled parent and themselves be cancelled rather than dropped as orphans.
  • Comprehensive tests — three new unit tests cover: cancellation of a running chain, skipping a queued-but-unstarted chain, and preventing a cancelling chain from "resurrecting" itself by chaining new events during unwind.

Confidence Score: 5/5

Safe to merge. The supersession path is additive — handlers without the marker are completely unaffected — and on_load_internal is the only handler currently marked, limiting blast radius to navigation chains.

The cancellation cascade correctly handles all observable states of a root future: done-with-result (children still live), cancelled (task unwinding), and fully done. The deferred-cleanup guard in _try_clean_future prevents premature removal while a cancelled task is still unwinding, and the explicit _try_clean_future call at the end of _finish_task ensures cleanup always completes. Three targeted unit tests plus a full integration test in test_state.py exercise the main paths (running chain cancelled, queued chain skipped, resurrection prevented).

No files require special attention. All changed files are internally consistent and the new _superseded dict is cleared on shutdown.

Important Files Changed

Filename Overview
packages/reflex-base/src/reflex_base/event/processor/event_processor.py Core supersession logic: _superseded dict, _supersede_previous, deferred cleanup guard in _try_clean_future, and post-task _try_clean_future call in _finish_task. All edge cases (already-done root with live children, task still unwinding, triple-navigation) are handled correctly.
packages/reflex-base/src/reflex_base/event/processor/future.py Adds supersede_key field to EventFuture; slots=True dataclass correctly includes the field in slots. No issues.
packages/reflex-base/src/reflex_base/event/init.py Adds SUPERSEDES_MARKER constant, EventHandler.supersedes property, and supersedes parameter to the event() decorator with correct marker application. Follows identical pattern to BACKGROUND_TASK_MARKER.
reflex/state.py Applies @event(supersedes=True) to on_load_internal cleanly via the new decorator parameter; no setattr hacks needed. Previous review concern about setattr at import time is fully resolved.
tests/units/reflex_base/event/processor/test_event_processor.py Three new supersession tests and one exception-handler recovery test; _drain_superseded now polls up to 100 ticks (from 20) with an explicit AssertionError on timeout, making it robust enough for CI. All scenarios well covered.
tests/units/test_state.py End-to-end integration test validates that on_load_internal.supersedes is True, a stale slow_handler is cancelled on second navigation, and the fresh navigation completes without waiting behind the stale chain.

Reviews (3): Last reviewed commit: "add reflex-base news fragment" | Re-trigger Greptile

Comment thread reflex/state.py Outdated
Comment on lines +815 to +824
async def _drain_superseded(ep: EventProcessor) -> None:
"""Give done callbacks a few ticks to clean the supersession tracking.

Args:
ep: The event processor to wait on.
"""
for _ in range(20):
if not ep._superseded:
return
await asyncio.sleep(0)

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.

P2 _drain_superseded polls rather than waiting on the chain

The helper spins up to 20 event-loop ticks and returns early only if _superseded is empty. It offers no signal if the dict is still populated after those ticks — the test just continues and then asserts ep._superseded == {}. If cleanup ever takes more than ~20 scheduling rounds (e.g., under CI load), the assertion can fail non-deterministically. A more reliable approach would be to yield one final tick after current.wait_all() (already awaited just before the call), or use asyncio.wait_for on a poll with an explicit timeout.

@codspeed-hq

codspeed-hq Bot commented Jul 7, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 26 untouched benchmarks
⏩ 8 skipped benchmarks1


Comparing FarhanAliRaza:cancel-event (d3dbe18) with main (f79d011)2

Open in CodSpeed

Footnotes

  1. 8 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on main (e54bc83) during the generation of this report, so f79d011 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

Comment on lines +407 to +408
# The chain this event belongs to was cancelled; the event is
# stillborn and never enters the queue.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
# The chain this event belongs to was cancelled; the event is
# stillborn and never enters the queue.
# The chain this event belongs to was cancelled, so cancel the
# tracker since this event will never enter the queue.

lets get rid of the potentially trama-linked terminology. "stillborn" could be triggering for some

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied the suggested wording.

self._try_clean_future(future)
if future is None or future.cancelled():
if future is not None:
self._try_clean_future(future)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

shouldn't this already hit since we add _try_clean_future as a done callback when we create the future?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You're right — entries examined here never had a task created, so cleanup is never deferred for them and the done callback always removes the future. Dropped the eager _try_clean_future calls in both dispatch paths.

Comment on lines +630 to +685
if future is not None and future.cancelled():
self._try_clean_future(future)
if future is None or future.cancelled():
if future is not None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

why did this conditional change structure like this? are these two nested conditionals not equivalent to what we had before?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

They're not equivalent: the old code treated a missing future as "proceed and dispatch". Pre-supersession that was unreachable in practice (nothing cancelled a queued root event), but now supersession cancels entries that are still sitting in the queue, and the cancel's _try_clean_future done callback removes the future from _futures before the entry is dequeued. So future is None has to mean "cancelled, skip" — otherwise a superseded entry whose cleanup callback already ran would execute anyway. The nested conditional is gone now (see next comment).

Comment thread reflex/state.py Outdated
Comment on lines +2487 to +2493
# A newer navigation supersedes the previous unfinished on_load chain for the
# same client token, cancelling its stale work (#6593).
setattr(
OnLoadInternalState.event_handlers["on_load_internal"].fn,
SUPERSEDES_MARKER,
True,
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

probably would be nice to expose this as a kwarg on rx.event like background

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — added supersedes as a kwarg on rx.event (mirroring background), and on_load_internal is now decorated with @event(supersedes=True) instead of the post-class setattr.

- expose supersedes as a kwarg on rx.event and mark on_load_internal
  with @event(supersedes=True) instead of post-class setattr
- only retain cancelled futures in _futures while their task unwinds, so
  the backend exception handler task can chain recovery events (with
  regression test)
- drop the redundant eager _try_clean_future calls in the dispatch paths;
  the future's done callback already handles cleanup
- reword cancelled-chain comment
- make _drain_superseded fail loudly instead of silently giving up
@FarhanAliRaza
FarhanAliRaza requested a review from masenf July 20, 2026 20:28
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.

2 participants