Skip to content

fix(view): implement better context management for loaders - #2757

Closed
Truevoly wants to merge 2 commits into
vuejs:mainfrom
Truevoly:fix/2684
Closed

fix(view): implement better context management for loaders#2757
Truevoly wants to merge 2 commits into
vuejs:mainfrom
Truevoly:fix/2684

Conversation

@Truevoly

@Truevoly Truevoly commented Jul 20, 2026

Copy link
Copy Markdown

close #2684

This PR is in draft, feel free to provide comments, suggestions, critique

This is my approach to resolve the issue with nested loader's context
I have found another way to reproduce the issue with context and that is by doing reload from component
Since provided solution by posva only updated useDataLoader and not load function (which is used when doing reload), I was worried that fix will not work for reload

Take a look at this stackblitz.
There are 4 pages, each page has two loaders that do not depend on each other, and a subpage, that has a loader that depends on those two. Difference between pages is how long it takes for each loader to resolve with a value.
Open console and navigate between pages, click reload after reloading has finished. While page will not go into infinite loop, it still logs a warning about incorrect context.

This stackblitz contains vue-router built from this branch. Open console and check logs, navigate between pages, reload, try and break it (and I mean it, I want this to be bulletproof)

I've started investigating and trying to follow the context during the different phases of execution. As it turns out, since context is only relevant for nested loaders, we can keep it empty for the most time, and only update on demand.

If our loader has nested loaders, it will call useDataLoader. When nested loader is finished, we reach finally. Here, we don't know if there are other nested loaders after us, so in case there are, we prepare context for them. We do it only if we're in a nested loader (parentEntry is not undefined). Now, context is restored back to original loader's context.
After finally finishes, promise is resolved, and two jobs are put into queue original loader and restoration of context back to empty. After doing some sync work, original loader can potentially call other nested loader, which will receive proper context. After that task is done, context is resolved. Any other call will receive empty context

During testing I've spotted a problem with this solution: if you have two nested loaders and second loader resolves before first, when dependent loader reaches second loader it triggers it again. So I've changed the condition on when the loader should be invoked. But now, when doing reload, nested loaders are never triggered. So I force it by resetting their to property, so the condition matches and loader is reloaded. I myself am not a fan of this solution, I welcome any suggestion on how to make it better

Summary by CodeRabbit

  • Bug Fixes

    • Improved nested data-loader behavior to prevent context leakage during concurrent loading.
    • Fixed reload handling so child loaders properly refetch their data.
    • Improved recovery and context restoration when loader requests fail.
  • Tests

    • Added coverage for nested lazy-loader loading and reload scenarios, including warning prevention and expected loader execution counts.

@netlify

netlify Bot commented Jul 20, 2026

Copy link
Copy Markdown

Deploy Preview for vue-router canceled.

Name Link
🔨 Latest commit 4396ee1
🔍 Latest deploy log https://app.netlify.com/projects/vue-router/deploys/6a5fae55664268000830e8fa

@Truevoly
Truevoly marked this pull request as draft July 20, 2026 13:23
@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Nested loader context handling

Layer / File(s) Summary
Context restoration and reload flow
packages/router/src/experimental/data-loaders/defineLoader.ts
Nested loader context restoration now occurs after promise settlement, nested reload checks compare the current route, and reloads invalidate child loader targets before refetching.
Context leakage regression coverage
packages/router/src/tests/data-loaders/tester.ts
Tests cover pending nested lazy loaders and reloads, asserting warning absence, expected call counts, and rendered output.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Component
  participant useDataLoader
  participant load
  participant ChildEntries
  Component->>useDataLoader: request nested loader
  useDataLoader->>load: start or refetch loader
  load-->>useDataLoader: settle promise
  useDataLoader->>Component: restore rendering context
  Component->>useDataLoader: trigger reload
  useDataLoader->>ChildEntries: invalidate child targets
  ChildEntries->>load: refetch child loaders
Loading

Suggested reviewers: posva

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes and tests address the reported nested-loader double-call, self-parenting, and infinite-loop behavior in #2684.
Out of Scope Changes check ✅ Passed The added reload-context fixes and tests stay aligned with the loader bug fix and do not introduce unrelated scope.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is clearly related to the loader context-management fix and reflects the main change in the PR.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/router/src/experimental/data-loaders/defineLoader.ts (1)

412-425: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document the microtask-ordering assumption behind this fix.

This relies on promise.then(onFinally, onFinally) being attached to promise while promise is settling (inside its own .finally()), so it runs after any handler already attached earlier (e.g. the caller's await), letting the resumed caller code see the restored parent context before it's cleared to []. This is a correct use of promise-handler attachment ordering, but it's non-obvious and easy to break with a seemingly innocuous refactor (e.g. wrapping/awaiting promise before this line, or returning a promise from the .finally() callback). A short comment explaining why the reset must be scheduled via promise.then(...) rather than done synchronously would help future maintainers avoid regressing this.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/router/src/experimental/data-loaders/defineLoader.ts` around lines
412 - 425, Add a concise comment in the `.finally()` callback explaining that
`promise.then(onFinally, onFinally)` must be attached to the settling promise to
schedule the context reset after earlier handlers, including the caller’s await,
while avoiding unhandled rejections. State that the reset must remain
asynchronous rather than calling `setCurrentContext([])` synchronously, and
preserve the existing promise structure.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/router/src/experimental/data-loaders/defineLoader.ts`:
- Around line 387-398: Update the reload handler to recursively invalidate every
descendant of the current loader, not only the direct entries in entry.children.
Traverse each childEntry and clear its to value, then continue through its
children before invoking load(to, router), so nested loaders such as A → B → C
are fully refetched.

---

Nitpick comments:
In `@packages/router/src/experimental/data-loaders/defineLoader.ts`:
- Around line 412-425: Add a concise comment in the `.finally()` callback
explaining that `promise.then(onFinally, onFinally)` must be attached to the
settling promise to schedule the context reset after earlier handlers, including
the caller’s await, while avoiding unhandled rejections. State that the reset
must remain asynchronous rather than calling `setCurrentContext([])`
synchronously, and preserve the existing promise structure.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 13d775b9-5c62-460a-9709-0c17bd0da2fa

📥 Commits

Reviewing files that changed from the base of the PR and between 94dca11 and 28a451a.

📒 Files selected for processing (2)
  • packages/router/src/experimental/data-loaders/defineLoader.ts
  • packages/router/src/tests/data-loaders/tester.ts

Comment on lines 387 to +398
reload: (to: RouteLocationNormalizedLoaded = router.currentRoute.value) =>
router[APP_KEY]
.runWithContext(() => load(to, router))
.runWithContext(() => {
const entry = entries.get(loader)!
entry.children.forEach(childEntry => {
// for child loaders we usually want to avoid refetching,
// that's why we're checking that requested and loaded routes are the same
// when reloading, we want to refetch though, and so we make those routes different
childEntry.to = null
})
return load(to, router)
})

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.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether any test exercises 3+ level nested loader reload
rg -n "children" packages/router/src/experimental/data-loaders/defineLoader.ts
rg -n "reload" packages/router/src/tests/data-loaders/tester.ts | head -50

Repository: vuejs/router

Length of output: 1845


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the reload implementation and surrounding child-entry logic.
sed -n '300,420p' packages/router/src/experimental/data-loaders/defineLoader.ts

echo '--- tester reload-related tests ---'
sed -n '1540,2035p' packages/router/src/tests/data-loaders/tester.ts

Repository: vuejs/router

Length of output: 19975


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find all places that mutate or traverse loader children, and inspect the load path.
rg -n "children\.|parentEntry\.children|entry\.children|commit\(to\)|load\(" packages/router/src/experimental/data-loaders/defineLoader.ts

echo '--- load/child relationship slice ---'
sed -n '1,260p' packages/router/src/experimental/data-loaders/defineLoader.ts

echo '--- middle slice around nested-loader association ---'
sed -n '260,420p' packages/router/src/experimental/data-loaders/defineLoader.ts

Repository: vuejs/router

Length of output: 15963


🏁 Script executed:

python3 - <<'PY'
# Model the relevant parts of defineLoader.ts:
# - each entry has to, pendingTo, children
# - reload() sets only direct children.to = None
# - useDataLoader() reloads a nested loader only when parentEntry exists and entry.to != route
# - commit() only propagates to current children, but does not itself trigger reloads

class Entry:
    def __init__(self, name):
        self.name = name
        self.to = None
        self.pendingTo = None
        self.children = []
        self.loaded = 0

    def commit(self, to):
        if self.pendingTo == to:
            self.to = to
            self.pendingTo = None
            for c in self.children:
                c.commit(to)

def load(entry, to):
    if entry.pendingTo == to:
        return
    entry.pendingTo = to
    entry.loaded += 1

def use_loader(entry, to, parent=None):
    if (entry.pendingTo is None) or (parent is not None and entry.to != to):
        load(entry, to)
    if parent is not None and entry not in parent.children:
        parent.children.append(entry)

def reload(entry, to):
    for c in entry.children:
        c.to = None
    load(entry, to)
    entry.commit(to)

# Initial chain A -> B -> C at route R1
A, B, C = Entry("A"), Entry("B"), Entry("C")
use_loader(A, "R1")
A.commit("R1")
use_loader(B, "R1", A)
B.commit("R1")
use_loader(C, "R1", B)
C.commit("R1")

# Reload A on the same route
reload(A, "R1")

print("A loaded:", A.loaded)
print("B loaded:", B.loaded)
print("C loaded:", C.loaded)
print("A.to/B.to/C.to:", A.to, B.to, C.to)
print("A.pending/B.pending/C.pending:", A.pendingTo, B.pendingTo, C.pendingTo)
PY

Repository: vuejs/router

Length of output: 259


Reload only clears direct children, so deeper descendants can stay stale. In a chain like A → B → C, reloading A resets B’s to, but C keeps its previous route and is reused when B reruns. That mixes fresh parent data with stale nested data; invalidate descendants recursively if 3+ levels are expected.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/router/src/experimental/data-loaders/defineLoader.ts` around lines
387 - 398, Update the reload handler to recursively invalidate every descendant
of the current loader, not only the direct entries in entry.children. Traverse
each childEntry and clear its to value, then continue through its children
before invoking load(to, router), so nested loaders such as A → B → C are fully
refetched.

posva added a commit that referenced this pull request Jul 23, 2026
Ports the reload regression test from #2757 and adds coverage for
reloading deeply nested loaders. Runs for both the basic and the Pinia
Colada loaders.

Co-authored-by: Ilia Bolotov <Ilia_bolotov@epam.com>
@posva

posva commented Jul 23, 2026

Copy link
Copy Markdown
Member

Thanks! See #2727 (comment)

@posva posva closed this Jul 23, 2026
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.

Nested loader is called twice and has itself as a parent when parent loader resolves instantly

2 participants