Skip to content

Lazy-load race condition leads to wrong component being rendered in UIView, requiring a full page reload #1239

@piotrnajda3000

Description

@piotrnajda3000

Hi, thank you for all the work on the router 🙏🏻 I've found an issue during development.

Code created from the Hello World UI router example:

Steps to reproduce

  1. Load the app in / route: https://react-javascript--piotrnajda3000.replit.app/
  2. Enable 3G Network throttling to simulate slow lazy-loading
  3. Two steps in quick succession: switch to "Buckets" and then to "Test", before "Buckets" finishes loading

Expected

"Test" UI (test.jsx) is visible.

Actual

"Buckets" UI (buckets.jsx) is visible.

Additionally, if you try to go to "Buckets" UI and then back to "Test", still, "Buckets" UI will be rendered. This requires a full page reload.

Video reproduction (w/ 3G throttling enabled)

2026-01-08.17-52-01.mp4

Additional notes

1 - If you let "Buckets" load, then switch to "Test", everything works as expected:

2026-01-08.17-54-24.mp4

2 - If we had three routes, e.g. "Buckets", "Documents", "Test", and with the throttling, we let "Buckets" load after navigating to it, then quickly switched between "Documents" and "Test", we'd see the "Documents" UI issue persisting on "Test" route, but it "Buckets" route would correctly load, since it was already pre-loaded.

Workaround

The code for workaround is implemented in the replit/zip. You only need to switch the lazyLoad (uncomment it in replit code).

Details

// Fix for lazy load race condition: track the latest transition
// and discard stale lazy load results
let latestLazyLoadId = 0;

// Now the fix works by:
//
// 1. Tracking a global latestLazyLoadId - incremented each time any lazy load starts
// 2. Each lazy load captures its ID - when the import() starts
// 3. After import completes, check if stale - if myId !== latestLazyLoadId, a newer navigation has started
// 4. Reject stale loads - returns Promise.reject(transition.abort()) which aborts the transition and prevents the stale states from being registered
//
// This ensures that if you click Test → Buckets quickly
// :
// - Test's lazy load gets ID 1
// - Buckets' lazy load gets ID 2
// - When Test's import() resolves, it checks: 1 !== 2, so it aborts itself
// - Only Buckets' lazy load proceeds to register its states
//
// You can test this by throttling to 3G and rapidly switching between routes - only the last clicked route should render.
function createTrackedLazyLoad(importFn) {
  return (transition) => {
    const myId = ++latestLazyLoadId;
    return importFn().then((module) => {
      // If a newer navigation started, reject this stale lazy load
      if (myId !== latestLazyLoadId) {
        return Promise.reject(transition.abort());
      }
      return module;
    });
  };
}

const bucketsState = {
  name: "app.admin.buckets.**",
  url: "/buckets",
  lazyLoad: createTrackedLazyLoad(() => import("./buckets.js")),
};

// ... Also apply to testState 

With workaround implemented:

2026-01-08.18-02-05.mp4

Thank you!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions