You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Route objects are never mutated. matchRoutes uses the sync result as a
local variable for matching; Router's .then() handler only triggers a
re-render via setLazyCache (no route.children assignment). After
resolution, subsequent matchRoutes calls invoke the user's function
which returns the cached array synchronously.
This keeps route definitions immutable — the user's caching function
is the single source of truth for resolution state.
https://claude.ai/code/session_01HqWiPByktzUpdesqZbNcHp
`matchRoutes` remains **synchronous**. When it encounters a route whose `children` is a function, it **calls the function** to determine how to proceed:
227
227
228
-
1.**Sync result** (function returns an array): The children are resolved immediately. `matchRoutes`mutates `route.children` to the resolved array and continues matching children — exactly as if they had been static all along.
228
+
1.**Sync result** (function returns an array): The children are resolved. `matchRoutes`uses the returned array for child matching within that call — exactly as if they had been static children.
229
229
230
230
2.**Async result** (function returns a promise): The children can't be resolved synchronously. The route matches as a prefix (same as having children), but only the parent is included in the match result.
231
231
@@ -236,29 +236,32 @@ function matchRoute(route, pathname, options) {
@@ -267,6 +270,8 @@ function matchRoute(route, pathname, options) {
267
270
}
268
271
```
269
272
273
+
**No mutation of route objects:**`matchRoutes` does not modify `route.children`. The returned array is used as a local variable for matching within that call. The function stays on the route object and is called again on subsequent `matchRoutes` invocations. Since the user's function caches its result, repeated calls are cheap (just return the cached value).
274
+
270
275
**Why `matchRoutes` calls the function:** This handles the case where the function returns a cached result synchronously (e.g., after a previous async resolution). If the function returns sync, matching continues without any Suspense or re-render — the route tree is fully resolved in a single pass. This is critical for resilience: if Router's state was lost (see [Router suspension edge case](#router-suspension)), the function returns sync on retry and `matchRoutes` resolves it immediately.
271
276
272
277
**Side effect in `matchRoutes`:** Calling the lazy function is technically a side effect (it may trigger a dynamic import on the first call). This is acceptable because: (1) it's idempotent — the user's caching ensures repeated calls return the same result, (2) it mirrors how `React.lazy` triggers loading on first render, and (3) in React strict mode, `useMemo` may run twice, but both calls get the same cached result.
**Note:** Router calls the lazy function a second time (after `matchRoutes` already called it). This is safe because the user's caching ensures the same Promise object is returned — no duplicate import is triggered. The Router needs to call it to attach its own `.then()` handler for the state update.
428
+
**Note:** Router calls the lazy function a second time (after `matchRoutes` already called it). This is safe because the user's caching ensures the same Promise object is returned — no duplicate import is triggered. The Router needs to call it to attach its own `.then()` handler that triggers the re-render via `setLazyCache`.
424
429
425
430
**How the promise reaches `PendingOutlet`:**
426
431
@@ -451,8 +456,8 @@ The Router's subscription to `currententrychange` wraps updates in `startTransit
451
456
6. On re-render: promise is in cache, `RouteRenderer` produces `<PendingOutlet promise={...}>`
452
457
7.`<PendingOutlet>` calls `use(promise)` → **suspends inside the transition**
453
458
8. React keeps the old page visible (transition behavior)
@@ -533,23 +536,13 @@ If `<Outlet />` is not wrapped in `<Suspense>`, the suspension propagates up to
533
536
534
537
### Resolution Caching
535
538
536
-
Caching happens at two levels:
539
+
Route objects are **never mutated**. `route.children` stays as the user-provided function for the lifetime of the route definition. Caching is handled entirely by the user's function and Router's state:
537
540
538
541
1.**User-level caching** (required): The lazy function itself caches its result. On first call it returns a Promise; on subsequent calls it returns the resolved array synchronously. This is the user's responsibility (see [Caching contract](#caching-contract)). This is the **primary** caching mechanism and the one that ensures resilience when React state is lost.
539
542
540
-
2.**In-place mutation** (optimization): After resolution, `matchRoutes` (sync path) and Router's `.then()` handler (async path) both mutate `route.children` to the resolved array:
Once mutated, `matchRoutes` sees the array and takes the normal static-children path — the function is never called again. The route tree is indistinguishable from a fully-static one after all lazy subtrees are resolved.
543
+
2.**Router's `lazyCache` state**: Stores in-flight promises so `PendingOutlet` can suspend via `use()`. Its identity change (new `Map` reference) triggers `matchedRoutesWithData` recomputation after a promise resolves.
551
544
552
-
**Trade-off:** This mutates the route definition objects. Since `route()` returns the input object as-is (just a type assertion), the mutation affects the original object passed by the user. This is intentional — the mutation is an optimization that replaces a one-shot factory with its result.
545
+
After resolution, subsequent `matchRoutes` calls invoke the function again — but the user's cache returns the resolved array synchronously, so matching completes in a single pass with no suspension. The function call is cheap (a cache lookup) and keeps the route objects immutable.
553
546
554
547
## Edge Cases
555
548
@@ -598,7 +591,7 @@ Navigate to: /admin/nonexistent
598
591
2. Navigation is intercepted (parent matched)
599
592
3. Navigation commits → React renders in transition
5. Lazy children resolve → `setLazyCache` → `matchRoutes` re-runs (function now returns sync)
602
595
6.`/admin` matches, but no child matches `/nonexistent`
603
596
7. If `requireChildren` is true (default): re-match returns `null`
604
597
8. Nothing renders
@@ -626,7 +619,7 @@ In practice, this is not a problem: for a user to submit a form on `/admin/setti
626
619
627
620
If the user navigates away while lazy children are loading:
628
621
629
-
- The in-flight lazy resolution continues in the background. When it resolves, it installs children into the route tree and calls `setLazyCache` to create a new `Map` reference. This is harmless — the installed children are correct and will be available for future navigations.
622
+
- The in-flight lazy resolution continues in the background. When it resolves, `.then()`calls `setLazyCache` to create a new `Map` reference. This is harmless — the user's cache stores the result, and future `matchRoutes` calls will resolve synchronously.
630
623
- The new navigation triggers a fresh `startTransition`, which supersedes the old one. React discards the old transition's render tree.
631
624
- If the new navigation path also requires lazy resolution, a new `PendingOutlet` handles it independently.
632
625
@@ -647,7 +640,7 @@ For SSR-critical routes, users should define children statically. Lazy children
647
640
648
641
### Route definitions prop change
649
642
650
-
If the `routes` prop passed to `<Router>` changes (new array/new objects), previously resolved lazy children live on the old objects. The new route definitions may have fresh lazy functions that need resolution. This works correctly because `matchRoutes`checks `typeof route.children === 'function'` — fresh functions trigger resolution, while already-resolved arrays (or mutated children) are matched synchronously.
643
+
If the `routes` prop passed to `<Router>` changes (new array/new objects), the new route definitions may have fresh lazy functions that need resolution. Router clears its `lazyCache` (derived state pattern), and `matchRoutes`calls the new functions on the next render. Previously resolved children on old route objects are unaffected — they still have their original functions, and the user's cache for those functions still works.
651
644
652
645
### Router suspension
653
646
@@ -658,17 +651,12 @@ With sync return from the user's cache:
658
651
1. Router renders (first time, no state) → `matchRoutes` calls function → Promise → partial match
659
652
2. Router registers in lazyCache (setState-during-render) → PendingOutlet suspends
660
653
3. State is lost (tree discarded by parent Suspense)
661
-
4. Promise resolves in background → `.then()`mutates `route.children` (mutation persists on the route object even though React state is gone)
654
+
4. Promise resolves in background (`.then()`calls `setLazyCache` which is now stale — harmless no-op)
662
655
5. Later, React re-renders the tree from scratch
663
-
6. Router renders fresh → `matchRoutes` sees `route.children` is now an array (mutated) → full match
664
-
7. No suspension needed
665
-
666
-
Even if the in-place mutation from `.then()` hasn't run yet (race condition), the user's cache provides a second line of defense:
667
-
668
-
1.`matchRoutes` calls function → sync (user cached result) → mutates `route.children` → full match
669
-
2. No suspension, no lazyCache needed
656
+
6. Router renders fresh → `matchRoutes` calls function → sync (user cached result) → full match
657
+
7. No suspension needed, no lazyCache needed
670
658
671
-
This is why the [caching contract](#caching-contract) matters: it ensures that the system converges to a resolved state regardless of how many times React state is discarded.
659
+
This is why the [caching contract](#caching-contract) matters: it ensures that the system converges to a resolved state regardless of how many times React state is discarded. The user's function is the single source of truth for resolution state — not React state, and not route object mutation.
- When `typeof route.children === 'function'`: call the function
798
-
- If result is an array (sync): mutate `route.children`, continue matching children normally
786
+
- If result is an array (sync): use it for child matching (no mutation of route objects)
799
787
- If result is a Promise (async): treat as having children for `isExact`, return parent-only match (bypass `requireChildren`)
800
788
801
789
### Step 3: Add `lazyCache` to `RouterContext`
@@ -847,7 +835,7 @@ Test cases:
847
835
11.**Suspense fallback shown on initial load**: Verify that the `<Suspense>` boundary around `<Outlet />` shows its fallback during lazy resolution on initial load.
848
836
12.**No Suspense fallback during navigation**: Verify that during navigation, the old page stays visible (transition behavior) and no Suspense fallback is shown.
849
837
13.**Sync resolution (cached function)**: Lazy function returns array synchronously on second call. Verify `matchRoutes` resolves it immediately with no suspension.
850
-
14.**`matchRoutes` handles sync return**: Call `matchRoutes` with a route whose children function returns an array. Verify full match is returned and `route.children` is mutated to the array.
838
+
14.**`matchRoutes` handles sync return**: Call `matchRoutes` with a route whose children function returns an array. Verify full match is returned and `route.children` is NOT mutated (still a function).
0 commit comments