Skip to content

Conversation

Sheraff
Copy link
Contributor

@Sheraff Sheraff commented Aug 23, 2025

This PR flattens the loadRouteMatch in load-matches.ts. This makes it easier to understand what code runs in which conditions, and makes it possible to not return a promise at all in some situations.

Summary by CodeRabbit

  • Refactor
    • Optimized route loading to handle synchronous and asynchronous results more efficiently, improving parallel loading and reducing blocking during navigation.
    • Centralized finalization of route loads for more consistent state updates (fetching, invalidation, timeouts) and smoother transitions.
    • Streamlined preload behavior to align with the unified loading flow, maintaining stale-while-revalidate semantics.
    • Internal performance and reliability improvements with no changes to the public API.

Copy link

coderabbitai bot commented Aug 23, 2025

Walkthrough

Refactors router-core load flow to use an Awaitable-based pattern. Replaces several awaits with promise chaining, adds settleLoadRouteMatch() for centralized finalization, updates type annotations, and reorganizes preload/stale-while-revalidate handling. Server mode head handling now chains via then(). No exported/public API signatures changed.

Changes

Cohort / File(s) Summary of changes
Router core async orchestration
`packages/router-core/src/load-matches.ts`
Switch to Awaitable<T>; change internal types to Array<Awaitable<AnyRouteMatch>>; refactor loadRouteMatch to return Awaitable; centralize finalization in settleLoadRouteMatch(); adjust server head result handling to then(); unify preload and SWR state flow; minor imports updated.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor Caller
  participant Router as Router
  participant Load as loadMatches
  participant Match as loadRouteMatch
  participant Final as settleLoadRouteMatch

  Caller->>Router: navigate()/preload()
  Router->>Load: loadMatches(matches)
  loop for each route match
    Load->>Match: loadRouteMatch(match, ctx)
    alt loader already running
      Match-->>Load: loaderPromise.then(updatedMatch)
    else new load
      alt server mode (head)
        Match-->>Load: headResult.then(updateMatch)
      else client mode
        Match-->>Load: Awaitable<AnyRouteMatch>
      end
    end
    Load->>Final: settleLoadRouteMatch(match, state)
    Final-->>Load: match (finalized)
  end
  Load-->>Router: Array<Awaitable<AnyRouteMatch>>
  Router-->>Caller: resolution when each settles
  note over Final,Load: Centralizes clearing timeouts, resolving promises,<br/>resetting dehydration, updating isFetching/invalid
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • schiller-manuel
  • SeanCassiere

Poem

Little paws tap-tap on the route parade,
I weave Awaitables in a seamless braid.
Promises hop, then settle in a nest,
Timeouts cleared, matches looking their best.
Preloads nibble, stale crumbs revalidate—
A happy hare shipping orchestrated fate. 🐇✨

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch refactor-router-core-flatten-load-route-match

🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

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

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link

nx-cloud bot commented Aug 23, 2025

View your CI Pipeline Execution ↗ for commit fead952

Command Status Duration Result
nx affected --targets=test:eslint,test:unit,tes... ✅ Succeeded 4m 22s View ↗
nx run-many --target=build --exclude=examples/*... ✅ Succeeded 1m 38s View ↗

☁️ Nx Cloud last updated this comment at 2025-08-23 14:16:00 UTC

Copy link

pkg-pr-new bot commented Aug 23, 2025

More templates

@tanstack/arktype-adapter

npm i https://pkg.pr.new/TanStack/router/@tanstack/arktype-adapter@5018

@tanstack/directive-functions-plugin

npm i https://pkg.pr.new/TanStack/router/@tanstack/directive-functions-plugin@5018

@tanstack/eslint-plugin-router

npm i https://pkg.pr.new/TanStack/router/@tanstack/eslint-plugin-router@5018

@tanstack/history

npm i https://pkg.pr.new/TanStack/router/@tanstack/history@5018

@tanstack/react-router

npm i https://pkg.pr.new/TanStack/router/@tanstack/react-router@5018

@tanstack/react-router-devtools

npm i https://pkg.pr.new/TanStack/router/@tanstack/react-router-devtools@5018

@tanstack/react-router-ssr-query

npm i https://pkg.pr.new/TanStack/router/@tanstack/react-router-ssr-query@5018

@tanstack/react-start

npm i https://pkg.pr.new/TanStack/router/@tanstack/react-start@5018

@tanstack/react-start-client

npm i https://pkg.pr.new/TanStack/router/@tanstack/react-start-client@5018

@tanstack/react-start-plugin

npm i https://pkg.pr.new/TanStack/router/@tanstack/react-start-plugin@5018

@tanstack/react-start-server

npm i https://pkg.pr.new/TanStack/router/@tanstack/react-start-server@5018

@tanstack/router-cli

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-cli@5018

@tanstack/router-core

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-core@5018

@tanstack/router-devtools

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-devtools@5018

@tanstack/router-devtools-core

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-devtools-core@5018

@tanstack/router-generator

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-generator@5018

@tanstack/router-plugin

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-plugin@5018

@tanstack/router-ssr-query-core

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-ssr-query-core@5018

@tanstack/router-utils

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-utils@5018

@tanstack/router-vite-plugin

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-vite-plugin@5018

@tanstack/server-functions-plugin

npm i https://pkg.pr.new/TanStack/router/@tanstack/server-functions-plugin@5018

@tanstack/solid-router

npm i https://pkg.pr.new/TanStack/router/@tanstack/solid-router@5018

@tanstack/solid-router-devtools

npm i https://pkg.pr.new/TanStack/router/@tanstack/solid-router-devtools@5018

@tanstack/solid-start

npm i https://pkg.pr.new/TanStack/router/@tanstack/solid-start@5018

@tanstack/solid-start-client

npm i https://pkg.pr.new/TanStack/router/@tanstack/solid-start-client@5018

@tanstack/solid-start-plugin

npm i https://pkg.pr.new/TanStack/router/@tanstack/solid-start-plugin@5018

@tanstack/solid-start-server

npm i https://pkg.pr.new/TanStack/router/@tanstack/solid-start-server@5018

@tanstack/start-client-core

npm i https://pkg.pr.new/TanStack/router/@tanstack/start-client-core@5018

@tanstack/start-plugin-core

npm i https://pkg.pr.new/TanStack/router/@tanstack/start-plugin-core@5018

@tanstack/start-server-core

npm i https://pkg.pr.new/TanStack/router/@tanstack/start-server-core@5018

@tanstack/start-server-functions-client

npm i https://pkg.pr.new/TanStack/router/@tanstack/start-server-functions-client@5018

@tanstack/start-server-functions-fetcher

npm i https://pkg.pr.new/TanStack/router/@tanstack/start-server-functions-fetcher@5018

@tanstack/start-server-functions-server

npm i https://pkg.pr.new/TanStack/router/@tanstack/start-server-functions-server@5018

@tanstack/start-storage-context

npm i https://pkg.pr.new/TanStack/router/@tanstack/start-storage-context@5018

@tanstack/valibot-adapter

npm i https://pkg.pr.new/TanStack/router/@tanstack/valibot-adapter@5018

@tanstack/virtual-file-routes

npm i https://pkg.pr.new/TanStack/router/@tanstack/virtual-file-routes@5018

@tanstack/zod-adapter

npm i https://pkg.pr.new/TanStack/router/@tanstack/zod-adapter@5018

commit: fead952

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (5)
packages/router-core/src/route.ts (1)

1210-1242: Deprecate parentMatchPromise & Clarify Awaitable Semantics

The name parentMatchPromise still implies a Promise-only API, but its type is now Awaitable<…>—it may be a raw value or a Promise. To avoid confusing JS consumers (who might do ctx.parentMatchPromise.then(…) and hit runtime errors when it’s not thenable), we should:

  • Deprecate the old name and introduce a clearer alias.
  • Update the JSDoc to state it can be either a value or a Promise, and show how to normalize it.
  • Recommend using await or Promise.resolve() against it.

Locations to update:

packages/router-core/src/route.ts:1237

  • Add a deprecation notice on parentMatchPromise in its JSDoc.
  • Document that it’s an Awaitable<…> (value | Promise) and suggest Promise.resolve(ctx.parentMatchPromise) or await ctx.parentMatchPromise.

packages/router-core/src/route.ts

  • Introduce a new readonly alias property:
    /**
     * The parent route match, as a value or Promise.
     * @category loader
     */
    parentMatch: Awaitable<MakeRouteMatchFromRoute<TParentRoute>>;
  • Mark parentMatchPromise as deprecated, e.g.:
    /**
     * @deprecated Use `parentMatch` instead.
     * A value or Promise for the parent route match. Normalize via `await` or `Promise.resolve()`.
     **/
    parentMatchPromise: TId extends RootRouteId
      ? never
      : Awaitable<MakeRouteMatchFromRoute<TParentRoute>>;

These changes preserve backward compatibility while guiding users toward the clearer API.

packages/router-core/src/load-matches.ts (4)

548-576: Type-safety for parentMatchPromise: avoid as any and omit on root

To prevent accidental runtime presence of a never field for root routes and to preserve legacy .then(...) consumers, consider always wrapping non-root parent with Promise.resolve(...) and omitting the property for root.

Apply this diff:

-const getLoaderContext = (
+const getLoaderContext = (
   inner: InnerLoadContext,
   matchId: string,
   index: number,
   route: AnyRoute,
 ): LoaderFnContext => {
-  const parentMatchPromise = inner.matchPromises[index - 1] as any
+  const hasParent = index > 0 && route.id !== rootRouteId
+  const parentMatchPromise = hasParent
+    ? Promise.resolve(inner.matchPromises[index - 1]!)
+    : undefined
   const { params, loaderDeps, abortController, context, cause } =
     inner.router.getMatch(matchId)!
 
   const preload = resolvePreload(inner, matchId)
 
-  return {
+  const baseCtx = {
     params,
     deps: loaderDeps,
     preload: !!preload,
-    parentMatchPromise,
     abortController,
     context,
     location: inner.location,
     navigate: (opts) =>
       inner.router.navigate({
         ...opts,
         _fromLocation: inner.location,
       }),
     cause: preload ? 'preload' : cause,
     route,
-  }
+  } as any
+  if (hasParent) baseCtx.parentMatchPromise = parentMatchPromise
+  return baseCtx
 }

This keeps runtime shape consistent (no parentMatchPromise on root) and preserves backwards compatibility for non-TS users by always providing a Promise when present.


784-788: Preload disabled: ensure preload flag doesn’t stay true

If preload is requested but route.options.preload === false, we early return — good. However, preload may have just been set to true above; keeping it set could affect future logic (e.g., the “don’t swallow errors if preload” branch). Consider resetting it before returning.

Apply this diff:

   if (preload && route.options.preload === false) {
-    // Do nothing
-    return settleLoadRouteMatch()
+    // Do nothing, and make sure we don't carry a stale preload flag
+    if (match.preload) {
+      inner.updateMatch(matchId, (prev) => ({ ...prev, preload: false }))
+    }
+    return settleLoadRouteMatch()
   }

586-589: Typo in comment

“We''ll” → “we’ll”.

Apply this diff:

-    // a minimum duration, we''ll wait for it to resolve
+    // a minimum duration, we’ll wait for it to resolve

554-565: No internal .then calls detected on parentMatchPromise – optional compatibility safeguard

I ran a repo-wide search and found zero occurrences of calling .then(...) (including optional-chained ?.then) on parentMatchPromise outside of tests, where we use await exclusively. This means there’s no immediate breakage inside our codebase.

However, because we’ve changed parentMatchPromise to be potentially a direct (synchronous) value, JS‐only consumers who did call .then(...) would encounter a runtime error. To smooth migration for downstream JS users, you can:

  • Always wrap the return value in a promise at runtime (e.g. Promise.resolve(parentMatchPromise)) while keeping the TypeScript signature as Awaitable<T>.
  • Or document the change and recommend users switch any ctx.parentMatchPromise.then(…) calls to either await ctx.parentMatchPromise or Promise.resolve(ctx.parentMatchPromise).then(…).

No existing code needs updating, but you may choose one of those approaches to maintain public-API consistency.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 40f9ab7 and 85f379c.

📒 Files selected for processing (2)
  • packages/router-core/src/load-matches.ts (5 hunks)
  • packages/router-core/src/route.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
packages/router-core/src/route.ts (2)
packages/router-core/src/utils.ts (1)
  • Awaitable (4-4)
packages/router-core/src/Matches.ts (1)
  • MakeRouteMatchFromRoute (207-215)
packages/router-core/src/load-matches.ts (2)
packages/router-core/src/index.ts (1)
  • Awaitable (316-316)
packages/router-core/src/utils.ts (1)
  • isPromise (467-475)
🔇 Additional comments (12)
packages/router-core/src/route.ts (1)

1237-1242: Widened parentMatchPromise to Awaitable — good call

Using Awaitable here unblocks sync paths and aligns with the flattened loader flow.

packages/router-core/src/load-matches.ts (11)

7-7: Importing Awaitable keeps this file self-contained

Explicitly importing Awaitable improves local readability and makes the intent clear.


38-39: matchPromises now holds Awaitable — aligns with mixed sync/async flow

This lets downstream contexts access either a settled match or a thenable seamlessly.


707-716: loadRouteMatch now returns Awaitable — flatter and easier to reason about

Dropping async and returning an Awaitable clarifies sync vs async paths and avoids unnecessary microtasks.


717-729: Server skip path updates head via .then without forcing an await

Nice touch. This prevents stalling the server pipeline when only head assets need updating.


736-750: While a loader is running, return stale data unless it was a preload

This preserves SWR semantics and avoids swallowing preload errors. The follow-up .then correctly re-throws via handleRedirectAndNotFound if needed.


753-772: Staleness/shouldReload computation reads clean; shouldReload gets full context

Passing the LoaderFnContext into a functional shouldReload enables rich decisions (and now supports sync parent via Awaitable).


791-809: Background SWR path: Redirects handled; what about NotFound?

In the async IIFE, the catch handles redirects (navigate) but ignores NotFound. That’s likely intentional (background revalidation shouldn’t tear down navigation), but please confirm. If unintentional, decide whether to surface NotFound to the router error boundary or keep current behavior.


831-855: Centralized settlement is solid; cleans pending timeout and dehydration, normalizes flags

The settleLoadRouteMatch helper makes lifecycle transitions much clearer and reduces duplication.


889-896: Conditional Promise.all avoids needless microtask churn

The hasPromises guard is a nice micro-optimization. It preserves sync flows when everything settled synchronously.


578-646: Minor: head execution awaits consistently in runLoader; client/server parity preserved

The switch to capturing headResult then awaiting keeps behavior consistent and readable.


858-911: End-to-end flow remains robust with early onReady triggering and error handling

The try/catch around the entire sequence, plus final return inner.matches, keeps external API stable.

@github-actions github-actions bot added the documentation Everything documentation related label Aug 23, 2025
Copy link

@coderabbitai coderabbitai bot left a comment

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 (3)
packages/router-core/src/utils.ts (1)

187-194: Make return-promise detection robust for unions and thenables

Current check treats mixed sync/async return types as boolean and ignores PromiseLike. If the loader’s return is a union (e.g., Promise<T> | T | void), downstream narrowing that relies on true | false can become imprecise. Also, treating PromiseLike as async is safer and aligns with runtime then-duck-typing.

Consider this refinement that:

  • Returns true if all branches are promises,
  • false if none are,
  • boolean if some are promises and some are not,
  • and treats PromiseLike as async.
-export type LooseIsReturnPromise<T> = T extends (
-  ...args: Array<any>
-) => infer TReturn
-  ? TReturn extends Promise<any>
-    ? true
-    : false
-  : never
+export type LooseIsReturnPromise<T> = T extends (...args: Array<any>) => infer R
+  ? [Extract<R, Promise<any> | PromiseLike<any>>] extends [never]
+    ? false
+    : [Exclude<R, Promise<any> | PromiseLike<any>>] extends [never]
+      ? true
+      : boolean
+  : never
packages/router-core/src/route.ts (1)

425-430: asyncLoaderFn flag is reasonable, but be careful with mixed sync/async loaders

The unknown → boolean and never → boolean fallbacks are sensible. For loaders typed as a union (Promise<T> | T | void), LooseIsReturnPromise<TLoaderFn> evaluates to boolean. Downstream consumers must handle that uncertainty explicitly. See the parentMatchPromise comment below to ensure the ambiguity doesn’t incorrectly narrow to the sync branch.

If you keep the boolean outcome for mixed loaders, ensure all places that branch on asyncLoaderFn have a third branch for the ambiguous case.

docs/router/framework/react/api/router/RouteOptionsType.md (1)

149-150: Clarify root route special-case and mixed loader behavior

Two nitpicks to reduce confusion:

  • Mention that root routes have no parent match, so parentMatchPromise is never.
  • Suggest treating parentMatchPromise as Awaitable in examples when the parent loader can be sync or async.
 - `parentMatchPromise` is a promise _if_ the parent route's `loader` returns a promise as well, otherwise it is the resolved parent match object.
+ - `parentMatchPromise` is a promise _if_ the parent route's `loader` returns a promise; otherwise it is the resolved parent match object.
+ - For the root route, there is no parent match: `parentMatchPromise` is `never`.
+ - If a parent loader can be either sync or async, treat `parentMatchPromise` as an `Awaitable` in your code, e.g. `const parent = await Promise.resolve(parentMatchPromise)`.
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 85f379c and 0c18913.

📒 Files selected for processing (3)
  • docs/router/framework/react/api/router/RouteOptionsType.md (2 hunks)
  • packages/router-core/src/route.ts (3 hunks)
  • packages/router-core/src/utils.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
packages/router-core/src/route.ts (2)
packages/router-core/src/utils.ts (1)
  • LooseIsReturnPromise (187-193)
packages/router-core/src/Matches.ts (1)
  • MakeRouteMatchFromRoute (207-215)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Test
  • GitHub Check: Preview
🔇 Additional comments (2)
packages/router-core/src/route.ts (1)

27-30: Import looks good

Importing LooseIsReturnPromise here is appropriate and scoped to type-level usage only. No runtime impact.

docs/router/framework/react/api/router/RouteOptionsType.md (1)

134-137: Docs match the new behavior (Promise or resolved), good

Updating parentMatchPromise to a union type reflects the new sync/async behavior accurately.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (3)
packages/router-core/src/load-matches.ts (3)

586-590: Typo in comment.

Minor: “we''ll” → “we’ll”.

-    // a minimum duration, we''ll wait for it to resolve
+    // a minimum duration, we’ll wait for it to resolve

719-733: Server skip path updates head; client skip path does not — intentional?

On server skip you compute head via executeHead(...).then(...). On client skip you immediately return settleLoadRouteMatch() with no head refresh. If beforeLoad/context changes could affect head client-side even when loader is skipped (e.g., hydration with dehydrated), consider mirroring the server behavior for parity when route.options.head|scripts|headers exist.

If desired, conditionally compute head before settleLoadRouteMatch() on client when a head provider exists.


548-576: Public API note: widen parentMatchPromise in docs and types.

The PR description mentions widening parentMatchPromise from Promise<RouteMatch> to RouteMatch | Promise<RouteMatch>. Ensure docs and RouteOptionsType reflect the (likely) optionality for the root case.

I can open a follow-up patch to adjust the type and docs and to add tests covering root/child loader contexts if helpful.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 0c18913 and b070a68.

📒 Files selected for processing (1)
  • packages/router-core/src/load-matches.ts (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
packages/router-core/src/load-matches.ts (1)
packages/router-core/src/index.ts (4)
  • Awaitable (316-316)
  • AnyRouteMatch (90-90)
  • createControlledPromise (278-278)
  • isRedirect (372-372)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Test
  • GitHub Check: Preview
🔇 Additional comments (12)
packages/router-core/src/load-matches.ts (12)

7-7: Adopt Awaitable for internal async flows — good call.

Using a first-class Awaitable type will let loadRouteMatch short‑circuit synchronously without widening the public Promise surface unnecessarily.


38-38: matchPromises: Array<Awaitable<AnyRouteMatch>> aligns with mixed sync/async results.

This preserves type-safety while still allowing Promise.all(inner.matchPromises) to work with plain values. Nice.


707-716: Flattened loadRouteMatch returning Awaitable — solid structural refactor.

Clear separation of fast-path sync return vs async work. This should trim microtasks in common cases.


808-827: Head-only refresh path is correct and batched.

Good use of batch to coalesce the head update with settle. Ensures minimal extra renders.


840-866: Centralized finalization via settleLoadRouteMatch looks robust.

  • Properly cleans pending timeout and dehydration flags.
  • Resolves loaderPromise/loadPromise only when not running in background.
  • Idempotent isFetching/invalid updates, with batch to minimize churn.

Nice improvement in readability and correctness.


880-905: Parallel loader kickoff with mixed return types — works with Promise.all.

Pushing Awaitable results and then await Promise.all maintains correct execution order for parentMatchPromise while enabling sync short-circuit.


900-921: Early onReady trigger sequencing remains intact.

You still guarantee a single onReady resolution even when pending timers fire or errors short‑circuit — good.


523-546: Consider lazy head computation only when providers exist.

executeHead unconditionally allocates assetContext and runs Promise.all when any of head|scripts|headers exist — good. For completeness, you already short‑circuit when none exist. No change required.


141-156: Skip conditions are clear and side-effect free.

Hydration and server ssr === false checks are cheap and early — good for hot paths.


338-473: beforeLoad pipeline is well-ordered and resolves state consistently.

Abort controller propagation, pending state, and context merge are handled carefully; serial error handling flows into handleRedirectAndNotFound as expected.


578-705: runLoader happy/error paths handle head, pending, and status transitions cohesively.

All branches clear isFetching, set status, and merge head consistently. No change required.


554-576: I've added a script to display the full LoaderFnContext definition for review. Let's confirm the exact type of parentMatchPromise before updating the suggestion.

Comment on lines +784 to 801
status === 'success' && (invalid || (shouldReload ?? age > staleAge))
if (loaderShouldRunAsync && !inner.sync) {
loaderIsRunningAsync = true
;(async () => {
try {
await runLoader(inner, matchId, index, route)
} else {
// if the loader did not run, still update head.
// reason: parent's beforeLoad may have changed the route context
// and only now do we know the route context (and that the loader would not run)
const headResult = executeHead(inner, matchId, route)
if (headResult) {
const head = await headResult
inner.updateMatch(matchId, (prev) => ({
...prev,
...head,
}))
const match = inner.router.getMatch(matchId)!
match._nonReactive.loaderPromise?.resolve()
match._nonReactive.loadPromise?.resolve()
match._nonReactive.loaderPromise = undefined
} catch (err) {
if (isRedirect(err)) {
await inner.router.navigate(err.options)
}
}
}
})()
return settleLoadRouteMatch()
}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Avoid unhandled rejections during background redirects.

The async IIFE isn’t observed; awaiting inner.router.navigate(...) can leak unhandled rejections if navigation fails. Fire-and-forget instead.

-      } catch (err) {
-        if (isRedirect(err)) {
-          await inner.router.navigate(err.options)
-        }
-      }
+      } catch (err) {
+        if (isRedirect(err)) {
+          // Detach to avoid unhandled rejections in the background task
+          void inner.router.navigate(err.options)
+        }
+      }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
status === 'success' && (invalid || (shouldReload ?? age > staleAge))
if (loaderShouldRunAsync && !inner.sync) {
loaderIsRunningAsync = true
;(async () => {
try {
await runLoader(inner, matchId, index, route)
} else {
// if the loader did not run, still update head.
// reason: parent's beforeLoad may have changed the route context
// and only now do we know the route context (and that the loader would not run)
const headResult = executeHead(inner, matchId, route)
if (headResult) {
const head = await headResult
inner.updateMatch(matchId, (prev) => ({
...prev,
...head,
}))
const match = inner.router.getMatch(matchId)!
match._nonReactive.loaderPromise?.resolve()
match._nonReactive.loadPromise?.resolve()
match._nonReactive.loaderPromise = undefined
} catch (err) {
if (isRedirect(err)) {
await inner.router.navigate(err.options)
}
}
}
})()
return settleLoadRouteMatch()
}
status === 'success' && (invalid || (shouldReload ?? age > staleAge))
if (loaderShouldRunAsync && !inner.sync) {
loaderIsRunningAsync = true
;(async () => {
try {
await runLoader(inner, matchId, index, route)
const match = inner.router.getMatch(matchId)!
match._nonReactive.loaderPromise?.resolve()
match._nonReactive.loadPromise?.resolve()
match._nonReactive.loaderPromise = undefined
} catch (err) {
if (isRedirect(err)) {
// Detach to avoid unhandled rejections in the background task
void inner.router.navigate(err.options)
}
}
})()
return settleLoadRouteMatch()
}
🤖 Prompt for AI Agents
In packages/router-core/src/load-matches.ts around lines 784 to 801, the async
IIFE currently does "await inner.router.navigate(err.options)" inside the catch
block which can leak unhandled rejections when navigation fails; instead call
navigate in a fire-and-forget manner and ensure any rejection is handled locally
(e.g. invoke inner.router.navigate(err.options).catch(() => {/* optional log
*/}) or drop the await and attach a .catch handler) so the background redirect
cannot create an unhandled rejection.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (2)
packages/router-core/src/load-matches.ts (2)

771-784: Preload opt‑out can still flip match.preload to true (order-of-operations bug).

nextPreload is computed and applied before the route.options.preload === false early-return branch, so the match may be marked as preloaded even when the route opted out. Compute nextPreload to respect the route-level opt‑out and remove the now-redundant branch.

Apply:

-  const nextPreload =
-    !!preload && !inner.router.state.matches.some((d) => d.id === matchId)
+  const nextPreload =
+    !!preload &&
+    route.options.preload !== false &&
+    !inner.router.state.matches.some((d) => d.id === matchId)
@@
-  if (preload && route.options.preload === false) {
-    // Do nothing
-    return settleLoadRouteMatch()
-  }
+  // route.options.preload === false is respected via nextPreload computation above

792-805: Avoid potential unhandled rejections when redirecting from the background task.

Awaiting navigate inside the fire-and-forget IIFE can leak an unhandled rejection if navigation fails. Detach and attach a no-op catch.

Apply:

-      } catch (err) {
-        if (isRedirect(err)) {
-          await inner.router.navigate(err.options)
-        }
-      }
+      } catch (err) {
+        if (isRedirect(err)) {
+          // Detach to avoid surfacing unhandled rejections from a background redirect
+          void inner.router.navigate(err.options).catch(() => {})
+        }
+      }
🧹 Nitpick comments (2)
packages/router-core/src/load-matches.ts (2)

7-8: Adopted Awaitable correctly; tighten typing where it’s consumed to avoid any.

Importing Awaitable and widening matchPromises to Array<Awaitable<AnyRouteMatch>> aligns with the new mixed sync/async behavior. To keep types precise through the loader context, avoid the as any cast at the consumption site and express the union explicitly.

You can type parentMatchPromise in getLoaderContext like this (no behavioral change):

// in getLoaderContext(...)
const parentMatchPromise: Awaitable<AnyRouteMatch> | undefined =
  inner.matchPromises[index - 1]

Also applies to: 38-39


808-810: Prevent double-resolving loaderPromise in the sync-run branch.

runLoader already resolves match._nonReactive.loaderPromise. Calling settleLoadRouteMatch with loaderIsRunningAsync === false will resolve it again. It’s harmless if createControlledPromise is idempotent, but avoid the extra resolve for clarity.

Apply:

-  if (status !== 'success' || (loaderShouldRunAsync && inner.sync)) {
-    return runLoader(inner, matchId, index, route).then(settleLoadRouteMatch)
-  }
+  if (status !== 'success' || (loaderShouldRunAsync && inner.sync)) {
+    // Mark as async to avoid double-resolving the loaderPromise in settle
+    loaderIsRunningAsync = true
+    return runLoader(inner, matchId, index, route).then(settleLoadRouteMatch)
+  }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between b070a68 and 2373200.

📒 Files selected for processing (1)
  • packages/router-core/src/load-matches.ts (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
packages/router-core/src/load-matches.ts (1)
packages/router-core/src/index.ts (4)
  • Awaitable (316-316)
  • AnyRouteMatch (90-90)
  • createControlledPromise (278-278)
  • isRedirect (372-372)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Preview
  • GitHub Check: Test
🔇 Additional comments (2)
packages/router-core/src/load-matches.ts (2)

716-731: Server-side head update path looks correct.

Returning the match synchronously when only head assets need updating on the server keeps the new Awaitable contract intact and avoids unnecessary async hops.


833-855: Centralized settle covers necessary cleanup and state normalization.

Clearing pendingTimeout, resetting dehydrated, normalizing isFetching, and guarding extra store writes only when needed are all on point.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
packages/router-core/src/load-matches.ts (2)

773-786: Bug: route-level preload: false can still mark the match as preloaded.

You compute and apply nextPreload before checking route.options.preload === false. This can flip match.preload to true despite a route-level opt‑out.

Apply one of the following minimal fixes. Option A (preferred): short-circuit before touching preload:

-  const nextPreload =
-    !!preload && !inner.router.state.matches.some((d) => d.id === matchId)
-  prevMatch._nonReactive.loaderPromise = createControlledPromise<void>()
-  if (nextPreload !== prevMatch.preload) {
-    inner.updateMatch(matchId, (prev) => ({
-      ...prev,
-      preload: nextPreload,
-    }))
-  }
-
-  if (preload && route.options.preload === false) {
-    // Do nothing
-    return settleLoadRouteMatch()
-  }
+  if (preload && route.options.preload === false) {
+    // Respect route-level opt-out from preloading: don't mark the match as preloaded
+    return settleLoadRouteMatch()
+  }
+
+  const nextPreload =
+    !!preload && !inner.router.state.matches.some((d) => d.id === matchId)
+  prevMatch._nonReactive.loaderPromise = createControlledPromise<void>()
+  if (nextPreload !== prevMatch.preload) {
+    inner.updateMatch(matchId, (prev) => ({
+      ...prev,
+      preload: nextPreload,
+    }))
+  }

Option B: incorporate the opt‑out into the computation:

-  const nextPreload =
-    !!preload && !inner.router.state.matches.some((d) => d.id === matchId)
+  const nextPreload =
+    !!preload &&
+    route.options.preload !== false &&
+    !inner.router.state.matches.some((d) => d.id === matchId)

788-808: Avoid unhandled rejections when redirecting in the background.

The async IIFE is detached; awaiting inner.router.navigate(...) inside its catch can leak unhandled rejections if navigation fails.

-        if (isRedirect(err)) {
-          await inner.router.navigate(err.options)
-        }
+        if (isRedirect(err)) {
+          // Detach and guard against unhandled rejections
+          void inner.router.navigate(err.options).catch(() => {})
+        }

Also update the renamed flag if you adopt the previous nit (e.g., loaderRunningInBackground = true).

🧹 Nitpick comments (3)
packages/router-core/src/load-matches.ts (3)

709-717: Signature change to Awaitable<AnyRouteMatch> looks good; minor naming nit.

Variable loaderIsRunningAsync communicates intent, but loaderRunningInBackground would be more precise (it's about background execution vs. foreground sync, not just “async”).

-  let loaderIsRunningAsync = false
+  let loaderRunningInBackground = false

You’d also need to rename usages in settleLoadRouteMatch.


752-772: stale-while-revalidate computation is coherent; consider lazy context creation.

You compute shouldReload by calling shouldReloadOption(getLoaderContext(...)), which constructs a full loader context. If shouldReload is a boolean, the context work is wasted. Optional micro-optimization:

-  const shouldReload =
-    typeof shouldReloadOption === 'function'
-      ? shouldReloadOption(getLoaderContext(inner, matchId, index, route))
-      : shouldReloadOption
+  const shouldReload =
+    typeof shouldReloadOption === 'function'
+      ? shouldReloadOption(getLoaderContext(inner, matchId, index, route))
+      : shouldReloadOption

(No behavior change; only highlight that the function case is the only place context is needed.)


832-858: Centralized finalization is solid; minor polish.

  • Clearing pending timeout, resetting dehydration, and normalizing isFetching/invalid is correct.
  • Consider naming the flag loaderRunningInBackground for clarity if you accept the earlier nit.
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 2373200 and fead952.

📒 Files selected for processing (1)
  • packages/router-core/src/load-matches.ts (4 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
packages/router-core/src/load-matches.ts (1)
packages/router-core/src/utils.ts (2)
  • Awaitable (4-4)
  • createControlledPromise (388-412)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Test
🔇 Additional comments (6)
packages/router-core/src/load-matches.ts (6)

7-7: Good: importing Awaitable sets the stage for sync-or-async flows.

This aligns with the PR goal to enable synchronous execution paths where possible.


38-39: Widening matchPromises to Array<Awaitable<AnyRouteMatch>> is appropriate.

This lets loadRouteMatch return a value synchronously without forcing a promise. The later Promise.all(inner.matchPromises) will handle immediate values correctly.


718-733: Server skip path: head application via then(...) is sound.

Applying head and returning the updated match without forcing a full settle is appropriate on the server. No issues spotted.


735-750: In-flight loader handling preserves UX while avoiding error swallowing for preloads.

Returning the stale success match immediately (when not sync and not preload) and deferring to the in-flight promise is correct. The post-resolution error path properly funnels through handleRedirectAndNotFound.


810-812: Foreground path (sync or not-success) executes loader then settles — LGTM.

Chaining runLoader(...).then(settleLoadRouteMatch) ensures promises resolve and pending state clears in order.


814-830: Deferred head update when loader doesn’t run is correct and batched.

Batching the head application with settleLoadRouteMatch() minimizes updates. Looks good.

Comment on lines +554 to +556
const parentMatchPromise = Promise.resolve(
inner.matchPromises[index - 1] as any,
)
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Don’t wrap parentMatchPromise in Promise.resolve — preserve the new union type.

Wrapping with Promise.resolve(...) erases the benefit of the new public type (RouteMatch | Promise<RouteMatch>) by always forcing a microtask hop. Pass the underlying Awaitable through so a loader can synchronously access an already-settled parent when available.

Apply this diff:

-  const parentMatchPromise = Promise.resolve(
-    inner.matchPromises[index - 1] as any,
-  )
+  const parentMatchPromise =
+    inner.matchPromises[index - 1] as Awaitable<AnyRouteMatch>

Run this to confirm the public type actually advertises the union and that no downstream code assumes Promise<...>:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the LoaderFnContext type to ensure the union is declared
rg -nC3 -P '(type|interface)\s+LoaderFnContext\b' packages | sed -n '1,200p' || true
rg -nC2 -P 'parentMatchPromise\s*:\s*.*\|' packages || true

# Detect call sites still assuming Promise<...>
rg -nC2 -P 'parentMatchPromise\s*:\s*Promise<' packages || true
rg -nC2 -P 'await\s+[^;\n]*parentMatchPromise\b' packages || true

Length of output: 8611


Preserve the Awaitable type for parentMatchPromise and update affected tests

To retain the new RouteMatch | Promise<RouteMatch> union and avoid unnecessary microtask hops, remove the Promise.resolve wrapper in load-matches.ts:

--- a/packages/router-core/src/load-matches.ts
+++ b/packages/router-core/src/load-matches.ts
@@ -554,7 +554,7 @@
-  const parentMatchPromise = Promise.resolve(
-    inner.matchPromises[index - 1] as any,
-  )
+  const parentMatchPromise =
+    inner.matchPromises[index - 1] as Awaitable<AnyRouteMatch>

Because this change surfaces a union instead of always a Promise, update the test declarations that currently assume Promise<…> for parentMatchPromise. For example:

• packages/solid-router/tests/route.test-d.tsx
– Lines 415, 461, 655, 680, 704, 807, 1166
• change
parentMatchPromise: Promise<MakeRouteMatchFromRoute<typeof rootRoute>>
to
parentMatchPromise: Promise<MakeRouteMatchFromRoute<typeof rootRoute>> | MakeRouteMatchFromRoute<typeof rootRoute>

• packages/solid-router/tests/loaders.test.tsx
– Line 138
• update the loader context signature to reflect Awaitable<…> for parentMatchPromise

• packages/react-router/tests/route.test-d.tsx
– Lines 411, 462, 684, 709, 733, 836, 1203
• apply the same union-type adjustment as above

• packages/react-router/tests/loaders.test.tsx
– Line 159
• ensure the loader context declaration uses Awaitable<…>

Making these test updates is required so that the public API’s union type is correctly represented in both runtime and type-level tests.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const parentMatchPromise = Promise.resolve(
inner.matchPromises[index - 1] as any,
)
// Preserve the Awaitable<AnyRouteMatch> type instead of always wrapping in a Promise
const parentMatchPromise =
inner.matchPromises[index - 1] as Awaitable<AnyRouteMatch>
🤖 Prompt for AI Agents
In packages/router-core/src/load-matches.ts around lines 554 to 556, remove the
Promise.resolve wrapper so parentMatchPromise preserves the Awaitable type
(i.e., allow RouteMatch | Promise<RouteMatch> rather than always wrapping in a
Promise), and then update the affected test declarations to reflect the
union/Awaitable type: change the listed parentMatchPromise types in
packages/solid-router/tests/route.test-d.tsx (lines 415, 461, 655, 680, 704,
807, 1166) and packages/react-router/tests/route.test-d.tsx (lines 411, 462,
684, 709, 733, 836, 1203) from Promise<…> to Promise<…> | …
(MakeRouteMatchFromRoute<typeof rootRoute>), and update the loader context
signatures in packages/solid-router/tests/loaders.test.tsx (line 138) and
packages/react-router/tests/loaders.test.tsx (line 159) to use Awaitable<…>;
ensure types compile after these changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
documentation Everything documentation related package: router-core
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant