Skip to content

Conversation

@yanghuidong
Copy link
Contributor

@yanghuidong yanghuidong commented Dec 25, 2025

Fixes #6221

The bug: When loaders run asynchronously (stale-while-revalidate), loadRouteMatch returns immediately while the loader runs in the background. Promise.allSettled(inner.matchPromises) waits for loadRouteMatch promises, not the actual loader completion, causing head() to execute with undefined loaderData.

The fix: After loadRouteMatch promises settle, explicitly wait for all loaderPromises to complete before executing head functions. This ensures loaderData is available when head() executes.

Reproduction scenario:

  1. Navigate to authenticated route (e.g., /article/123)
  2. Delete auth cookie, reload (shows 'not found')
  3. Login, redirect to dashboard
  4. Click back button to /article/123
    • Before fix: Article loads but title shows fallback (loaderData undefined)
    • After fix: Article loads with correct title (loaderData available)

Fixes the issue identified in PR #6093 follow-up investigation.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added three routes: Fake Login, Dashboard, and dynamic Article (with auth gating).
    • Client-side fake-auth utility to simulate login state.
  • Bug Fixes

    • Improved head/meta handling with serial execution, safer re-run after async loaders settle, per-route error handling, and robust loader completion to avoid stale or hanging updates.
  • Tests

    • E2E tests for head behavior, login/logout flows, back navigation, and rapid article navigation.
  • Chores

    • E2E start script now uses pnpm dlx.

✏️ Tip: You can customize this high-level summary in your review settings.

The bug: When loaders run asynchronously (stale-while-revalidate),
loadRouteMatch returns immediately while the loader runs in the background.
Promise.allSettled(inner.matchPromises) waits for loadRouteMatch promises,
not the actual loader completion, causing head() to execute with undefined
loaderData.

The fix: After loadRouteMatch promises settle, explicitly wait for all
loaderPromises to complete before executing head functions. This ensures
loaderData is available when head() executes.

Reproduction scenario:
1. Navigate to authenticated route (e.g., /article/123)
2. Delete auth cookie, reload (shows 'not found')
3. Login, redirect to dashboard
4. Click back button to /article/123
   - Before fix: Article loads but title shows fallback (loaderData undefined)
   - After fix: Article loads with correct title (loaderData available)

Fixes the issue identified in PR TanStack#6093 follow-up investigation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 25, 2025

📝 Walkthrough

Walkthrough

Run all route head() functions serially after initial loaders finish; detect in-flight loaders and schedule a non-blocking re-run of head() once pending loaders settle (guarded by navigation-locality); ensure loader promises always resolve and preserve per-match error/redirect/notFound semantics.

Changes

Cohort / File(s) Summary
Router core: load & head orchestration
packages/router-core/src/load-matches.ts
Added executeAllHeadFns(inner) to serially run head() with per-call try/catch and update match head data; replaced inline per-match head execution with this helper; schedule a non-blocking re-execution of all head() when loaders are still pending using Promise.allSettled and navigation-locality checks; ensure loader promises resolve in finally to avoid hanging and preserve redirect/notFound behavior.
Generated route tree & types
e2e/solid-start/basic-solid-query/src/routeTree.gen.ts
Added three routes: /fake-login, /test-head/dashboard, /test-head/article/$id; propagated them through FileRoutesByFullPath/To/Id, FileRoutesByPath augmentation, RootRouteChildren, and route-tree construction.
New route pages
e2e/solid-start/basic-solid-query/src/routes/fake-login.tsx, e2e/solid-start/basic-solid-query/src/routes/test-head/dashboard.tsx, e2e/solid-start/basic-solid-query/src/routes/test-head/article.$id.tsx
Added Route exports: client-only login page (writes localStorage, invalidates authQy, navigates), dashboard with head() title, and dynamic article route with loader, head (derived from loaderData) and component implementing auth gating and login/logout flows.
Auth util & tests
e2e/solid-start/basic-solid-query/src/utils/fake-auth.ts, e2e/solid-start/basic-solid-query/tests/head.spec.ts
Added isAuthed and authQy client-only auth utilities; added Playwright e2e tests exercising head()/loader interactions, back navigation, login/logout flows, and rapid navigation title correctness.
E2E manifest tweak
e2e/solid-start/basic-solid-query/package.json
Switched start script invocation from pnpx srvx ... to pnpm dlx srvx ....

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant Client
  participant Router
  participant Loaders as "Loader(s)"
  participant HeadFns as "head() functions"
  participant Scheduler as "AsyncRe-run Scheduler"

  Client->>Router: navigate to /match
  Router->>Loaders: invoke match loaders (parallel)
  note right of Loaders `#f0f7ff`: some loader promises may remain pending
  Loaders-->>Router: some loader results resolved/rejected
  Router->>HeadFns: call executeAllHeadFns(inner) serially
  HeadFns-->>Router: apply per-match head data (errors caught)
  alt in-flight async loaders detected
    Router->>Scheduler: schedule non-blocking re-run when pending settle
    Scheduler->>Loaders: await pending loader promises (allSettled)
    Loaders-->>Scheduler: pending loaders settled
    alt navigation still current
      Scheduler->>HeadFns: re-invoke executeAllHeadFns(inner)
      HeadFns-->>Router: refreshed head data applied
    else navigation changed
      Scheduler-->>Router: abort re-run (stale)
    end
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • schiller-manuel

Poem

🐰 I hopped through loaders, sniffed each head,
I waited for promises, then hopped ahead.
If some slow pals linger in flight,
I’ll hop back later and set titles right.
Tiny paws, tidy hops — headlines bright 🥕

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix: wait for async loaders before executing head functions' directly and clearly describes the main change - ensuring head functions execute after loaders complete.
Linked Issues check ✅ Passed The changes comprehensively address issue #6221: head() now waits for async loader completion before executing, fixing premature execution that left loaderData undefined.
Out of Scope Changes check ✅ Passed All changes directly support the core objective of synchronizing head() execution with loader completion. The e2e test additions validate the fix with real-world navigation scenarios.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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 and usage tips.

@nx-cloud
Copy link

nx-cloud bot commented Dec 25, 2025

🤖 Nx Cloud AI Fix Eligible

An automatically generated fix could have helped fix failing tasks for this run, but Self-healing CI is disabled for this workspace. Visit workspace settings to enable it and get automatic fixes in future runs.

To disable these notifications, a workspace admin can disable them in workspace settings.


View your CI Pipeline Execution ↗ for commit 5b17c13

Command Status Duration Result
nx affected --targets=test:eslint,test:unit,tes... ❌ Failed 9m 3s View ↗
nx run-many --target=build --exclude=examples/*... ✅ Succeeded 1m 41s View ↗

☁️ Nx Cloud last updated this comment at 2025-12-26 09:13:28 UTC

@pkg-pr-new
Copy link

pkg-pr-new bot commented Dec 25, 2025

More templates

@tanstack/arktype-adapter

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

@tanstack/eslint-plugin-router

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

@tanstack/history

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

@tanstack/nitro-v2-vite-plugin

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

@tanstack/react-router

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

@tanstack/react-router-devtools

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

@tanstack/react-router-ssr-query

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

@tanstack/react-start

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

@tanstack/react-start-client

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

@tanstack/react-start-server

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

@tanstack/router-cli

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

@tanstack/router-core

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

@tanstack/router-devtools

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

@tanstack/router-devtools-core

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

@tanstack/router-generator

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

@tanstack/router-plugin

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

@tanstack/router-ssr-query-core

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

@tanstack/router-utils

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

@tanstack/router-vite-plugin

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

@tanstack/solid-router

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

@tanstack/solid-router-devtools

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

@tanstack/solid-router-ssr-query

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

@tanstack/solid-start

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

@tanstack/solid-start-client

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

@tanstack/solid-start-server

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

@tanstack/start-client-core

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

@tanstack/start-fn-stubs

npm i https://pkg.pr.new/TanStack/router/@tanstack/start-fn-stubs@6222

@tanstack/start-plugin-core

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

@tanstack/start-server-core

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

@tanstack/start-static-server-functions

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

@tanstack/start-storage-context

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

@tanstack/valibot-adapter

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

@tanstack/virtual-file-routes

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

@tanstack/vue-router

npm i https://pkg.pr.new/TanStack/router/@tanstack/vue-router@6222

@tanstack/vue-router-devtools

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

@tanstack/vue-router-ssr-query

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

@tanstack/vue-start

npm i https://pkg.pr.new/TanStack/router/@tanstack/vue-start@6222

@tanstack/vue-start-client

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

@tanstack/vue-start-server

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

@tanstack/zod-adapter

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

commit: 5b17c13

// Wait for async loaders to complete before executing head functions
// loadRouteMatch may return immediately while loaders run asynchronously in the background
// We need to wait for the actual loaderPromise, not just the loadRouteMatch promise
await Promise.all(
Copy link
Contributor

Choose a reason for hiding this comment

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

we definitely don't want to block for async loaders.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I guess it depends on what we're trying to do in the head function, right? For setting the document title, blocking until the loaderData is ready at least gets us the correct title, while not blocking, under certain scenarios, can get us nothing. Also, for page title, is blocking that bad?

Or do you see a non-blocking way to ensure page title is returned reliably? Thanks.

Copy link
Contributor

Choose a reason for hiding this comment

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

we probably need to detect if we have async loaders and then schedule to serially re-run all head() functions

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Are you saying, we let the head functions run a first-pass, non-blocking (current behavior), and then we check if there're any remaining unresolved loader promises, and if yes, then we await them (Promise.all), and then run the head functions for the second time?

Copy link
Contributor

Choose a reason for hiding this comment

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

possibly yes. the second run of head() must also run detached like the async loaders itself do

Copy link
Contributor Author

Choose a reason for hiding this comment

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

made an attempt, let me know if that could work?

yanghuidong and others added 2 commits December 26, 2025 03:04
When loaders run asynchronously (stale-while-revalidate), head()
executes immediately but may have stale loaderData. This change
detects async loaders and schedules a re-execution of all head()
functions after they complete, ensuring fresh metadata without
blocking navigation.

Benefits:
- Non-blocking: navigation completes immediately
- Correct metadata: title updates when fresh data arrives
- Consistent: all head() functions see complete loader state

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
Extracted head() execution loop into a reusable helper function to
eliminate code duplication between initial execution and re-execution
after async loaders complete.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
Copy link
Contributor

@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 (1)
packages/router-core/src/load-matches.ts (1)

955-957: Add error handling for the detached promise to avoid unhandled rejections.

The re-execution is correctly non-blocking per maintainer feedback. However, the returned promise from executeAllHeadFns isn't handled, which could lead to unhandled promise rejections if an unexpected error occurs (e.g., router state corruption).

Proposed fix
     if (asyncLoaderPromises.length > 0) {
       // Schedule re-execution after all async loaders complete (non-blocking)
-      Promise.all(asyncLoaderPromises).then(() => executeAllHeadFns(inner))
+      Promise.all(asyncLoaderPromises)
+        .then(() => executeAllHeadFns(inner))
+        .catch((err) => {
+          console.error('Error re-executing head functions after async loaders:', err)
+        })
     }
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d71da53 and 343eae0.

📒 Files selected for processing (1)
  • packages/router-core/src/load-matches.ts
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Use TypeScript strict mode with extensive type safety for all code

Files:

  • packages/router-core/src/load-matches.ts
**/*.{js,ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Implement ESLint rules for router best practices using the ESLint plugin router

Files:

  • packages/router-core/src/load-matches.ts
⏰ 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)

586-605: LGTM! Clean extraction of head execution logic.

The helper correctly implements serial execution with per-match error handling, ensuring one failing head() doesn't prevent others from running. The error logging approach is appropriate for this non-critical path.


948-953: Logic for detecting async loaders is correct.

The promise collection correctly identifies matches with in-flight async loaders by checking _nonReactive.loaderPromise, which is cleared upon completion (lines 845, 865). The optional chaining and .filter(Boolean) pattern safely handles edge cases.


if (asyncLoaderPromises.length > 0) {
// Schedule re-execution after all async loaders complete (non-blocking)
Promise.all(asyncLoaderPromises).then(() => executeAllHeadFns(inner))
Copy link
Contributor

Choose a reason for hiding this comment

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

can the detached loaders throw? if yes, should we execute the head chain? probably yes

Copy link
Contributor

Choose a reason for hiding this comment

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

we should probably store a reference to this promise so we can override it in case a new round of loaders has already started before the detached head() executed.

what if this happens while the detached head() runs?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Comparing location before executing the head seems to work? I tested with a slow fetcher, and navigate away before it can finish, and that check prevents the head function from executing.

Now if the head already starts executing, and we navigate to a new route, then the new head will execute next, and override the old value, hopefully? Not sure. Will think about it more after sleep. Insights/advice welcome as always.

Copy link
Contributor

Choose a reason for hiding this comment

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

comparing location is not enough IMO as you can stay on the same location and invalidate and trigger loaders.

@schiller-manuel
Copy link
Contributor

we need tests for this. unit tests and e2e test

yanghuidong and others added 2 commits December 26, 2025 04:17
Async loaders can throw errors. When this happens, head() functions
should still execute to set page metadata (e.g., error page titles).

The fix:
1. Use try-catch-finally to always resolve loaderPromise
2. Change Promise.all() to Promise.allSettled()

Why resolve on error:
- loaderPromise signals "completion" not "success"
- Errors are already stored in match.error by runLoader
- head() should execute with whatever state is available (success,
  error, or stale data)
- Without this, the promise hangs forever and head() never re-executes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
…execution

Addresses two scenarios:
1. New navigation starts BEFORE scheduled head() executes
2. New navigation starts WHILE head() is executing

Solution:
- Capture this navigation's location (thisNavigationLocation)
- Before executing head(), check if router's current location matches
- If location changed (new navigation), skip stale head() execution
- Location objects are always unique (parseLocation creates new objects)

Both concerns are resolved:
- Scenario 1: Location check prevents stale head() from executing
- Scenario 2: Stale head() may complete but fresh navigation
  overwrites with correct data immediately after

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
Copy link
Contributor

@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 (4)
packages/router-core/src/load-matches.ts (4)

586-605: Consider enhancing error handling and add early exit for efficiency.

The current implementation logs errors to console.error, which may not integrate with the router's error handling or observability strategy. Additionally, the function processes all matches even when they don't have head functions defined.

🔎 Suggested improvements

1. Enhanced error handling:

    } catch (err) {
-      console.error(`Error executing head for route ${routeId}:`, err)
+      // Consider using router's error handling mechanism or a callback
+      console.error(`Error executing head for route ${routeId}:`, err)
+      route.options.onError?.(err)
    }

2. Skip matches without head functions:

  for (const match of inner.matches) {
    const { id: matchId, routeId } = match
    const route = inner.router.looseRoutesById[routeId]!
+    // Skip if no head-related functions are defined
+    if (!route.options.head && !route.options.scripts && !route.options.headers) {
+      continue
+    }
    try {

947-970: Consider preventing concurrent head() executions.

The location check at line 966 prevents stale executions when a new navigation starts before the scheduled re-execution begins. However, if a new navigation starts during the executeAllHeadFns call itself, the currently executing head functions will complete even though they're now stale.

Based on past review comments, this was a concern raised by schiller-manuel: "what if this happens while the detached head() runs?"

🔎 Potential solution using an execution token

Store a reference to track the current execution and cancel/ignore stale ones:

+    let currentHeadExecutionToken = Symbol()
+    inner.router._currentHeadExecutionToken = currentHeadExecutionToken
+
     if (asyncLoaderPromises.length > 0) {
       const thisNavigationLocation = inner.location
       Promise.allSettled(asyncLoaderPromises).then(() => {
         const latestLocation = inner.router.state.location
-        if (latestLocation === thisNavigationLocation) {
+        if (latestLocation === thisNavigationLocation && 
+            inner.router._currentHeadExecutionToken === currentHeadExecutionToken) {
           executeAllHeadFns(inner)
         }
       })

Then in executeAllHeadFns, check the token before updating each match. However, this may be over-engineering for the "Chill" review mode.

Alternatively, verify whether the existing architecture already handles this scenario gracefully (e.g., if updateMatch is idempotent or location changes automatically invalidate head updates).


952-970: Optional: Consider selective head() re-execution.

Currently, all head functions are re-executed after async loaders complete, even if only a subset of matches had async loaders. While this ensures consistency (and handles cases where one match's head depends on another's loader data), it may result in unnecessary work.

🔎 Alternative: Track which matches have async loaders
-    const asyncLoaderPromises = inner.matches
+    const matchesWithAsyncLoaders = inner.matches
       .map((match) => {
         const currentMatch = inner.router.getMatch(match.id)
-        return currentMatch?._nonReactive.loaderPromise
+        return currentMatch?._nonReactive.loaderPromise ? match : null
       })
-      .filter(Boolean)
+      .filter((m): m is AnyRouteMatch => m !== null)
 
-    if (asyncLoaderPromises.length > 0) {
+    if (matchesWithAsyncLoaders.length > 0) {
+      const asyncLoaderPromises = matchesWithAsyncLoaders.map(
+        (m) => inner.router.getMatch(m.id)?._nonReactive.loaderPromise!
+      )
       const thisNavigationLocation = inner.location
       Promise.allSettled(asyncLoaderPromises).then(() => {
         const latestLocation = inner.router.state.location
         if (latestLocation === thisNavigationLocation) {
-          executeAllHeadFns(inner)
+          // Re-execute only affected matches
+          // (or keep current behavior if heads depend on each other)
+          executeAllHeadFns(inner)
         }
       })

However, keeping the current approach (re-executing all heads) is safer and simpler, as it handles cascading dependencies where one match's head might depend on another's loader data.


586-970: Tests are needed for the new head execution behavior.

As requested in the past review comments by schiller-manuel, this change requires both unit tests and e2e tests to cover:

  1. Head functions executing after synchronous loaders complete
  2. Head functions re-executing after async (stale-while-revalidate) loaders complete
  3. Head functions receiving correct loaderData in both executions
  4. Race condition handling when navigation occurs during async loader execution
  5. Error handling when head functions or async loaders throw

Based on past review comments requesting tests for this change.

Would you like me to help draft test scenarios or open an issue to track the test implementation?

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 343eae0 and 1c656a5.

📒 Files selected for processing (1)
  • packages/router-core/src/load-matches.ts
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Use TypeScript strict mode with extensive type safety for all code

Files:

  • packages/router-core/src/load-matches.ts
**/*.{js,ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Implement ESLint rules for router best practices using the ESLint plugin router

Files:

  • packages/router-core/src/load-matches.ts
⏰ 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 (1)
packages/router-core/src/load-matches.ts (1)

846-854: Good approach to ensure head() executes regardless of loader outcome.

The finally block correctly ensures that loader promises are always resolved (even on error), allowing head() functions to execute with whatever data is available. This aligns with the PR objective and addresses the concern from past comments about handling errors from detached loaders.

@yanghuidong
Copy link
Contributor Author

we need tests for this. unit tests and e2e test

There's one e2e test in a local branch that I'm using, is there any guideline for where to place it, and how to name it? It's currently called e2e/solid-start/test-head-bug, will that work? Should I merge it into here sooner, or later? Or even in a separate PR?

@schiller-manuel
Copy link
Contributor

please use an existing e2e project (e2e/react-start/basic for example) and add new routes in there and playwright tests

yanghuidong and others added 2 commits December 26, 2025 14:22
Adds test routes demonstrating the head() re-execution fix:
- /test-head/article/$id - auth-gated article with dynamic title
- /test-head/dashboard - simple dashboard for navigation
- /fake-login - simulates login with localStorage

Testing flow:
1. Visit /test-head/article/123 (unauthenticated)
   → Shows "Article Not Found" title & content
2. Click login link → simulate login → redirects to dashboard
3. Press browser BACK button
   → Article content loads correctly
   → Page title updates from stale to "Article 123 Title"
   → Console shows head() executing twice (non-blocking fix)

Note: fake-auth.ts uses localStorage for auth state

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
Adds comprehensive playwright tests verifying the head() re-execution fix:
- Verifies page title updates correctly on back navigation after login
- Tests fallback title when loader returns null
- Tests logout flow with correct title updates
- Verifies race condition handling with rapid navigation

Also fixes package.json start script to use 'pnpm dlx' instead of
deprecated 'pnpx' command for compatibility with modern pnpm versions.

All tests passing, confirming non-blocking head() re-execution works
correctly after async loaders complete.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
Copy link
Contributor

@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 (2)
e2e/solid-start/basic-solid-query/tests/head.spec.ts (1)

94-98: Minor: redundant assertion.

The expect(title).toBe(...) on line 96 already verifies the title matches. The follow-up assertion on line 97 is redundant.

🔎 Suggested simplification
     // Title should match the current route, not the previous one
-    const title = await page.title()
-    expect(title).toBe('Article 222 Title')
-    expect(title).not.toBe('Article 111 Title')
+    await expect(page).toHaveTitle('Article 222 Title')
e2e/solid-start/basic-solid-query/src/routes/test-head/article.$id.tsx (1)

30-36: Consider removing the debug log before merging.

The console.log on line 32 is useful during development but may add noise to test output.

🔎 Suggested change
   head: ({ loaderData }) => {
     const title = loaderData?.title || 'Article Not Found'
-    console.log('[!] head function: title =', title)
     return {
       meta: [{ title }],
     }
   },
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1c656a5 and 5914d25.

📒 Files selected for processing (7)
  • e2e/solid-start/basic-solid-query/package.json
  • e2e/solid-start/basic-solid-query/src/routeTree.gen.ts
  • e2e/solid-start/basic-solid-query/src/routes/fake-login.tsx
  • e2e/solid-start/basic-solid-query/src/routes/test-head/article.$id.tsx
  • e2e/solid-start/basic-solid-query/src/routes/test-head/dashboard.tsx
  • e2e/solid-start/basic-solid-query/src/utils/fake-auth.ts
  • e2e/solid-start/basic-solid-query/tests/head.spec.ts
🧰 Additional context used
📓 Path-based instructions (3)
**/package.json

📄 CodeRabbit inference engine (AGENTS.md)

Use workspace protocol workspace:* for internal dependencies in package.json files

Files:

  • e2e/solid-start/basic-solid-query/package.json
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Use TypeScript strict mode with extensive type safety for all code

Files:

  • e2e/solid-start/basic-solid-query/src/utils/fake-auth.ts
  • e2e/solid-start/basic-solid-query/src/routes/test-head/dashboard.tsx
  • e2e/solid-start/basic-solid-query/src/routes/fake-login.tsx
  • e2e/solid-start/basic-solid-query/src/routes/test-head/article.$id.tsx
  • e2e/solid-start/basic-solid-query/tests/head.spec.ts
  • e2e/solid-start/basic-solid-query/src/routeTree.gen.ts
**/*.{js,ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Implement ESLint rules for router best practices using the ESLint plugin router

Files:

  • e2e/solid-start/basic-solid-query/src/utils/fake-auth.ts
  • e2e/solid-start/basic-solid-query/src/routes/test-head/dashboard.tsx
  • e2e/solid-start/basic-solid-query/src/routes/fake-login.tsx
  • e2e/solid-start/basic-solid-query/src/routes/test-head/article.$id.tsx
  • e2e/solid-start/basic-solid-query/tests/head.spec.ts
  • e2e/solid-start/basic-solid-query/src/routeTree.gen.ts
🧠 Learnings (8)
📚 Learning: 2025-10-08T08:11:47.088Z
Learnt from: nlynzaad
Repo: TanStack/router PR: 5402
File: packages/router-generator/tests/generator/no-formatted-route-tree/routeTree.nonnested.snapshot.ts:19-21
Timestamp: 2025-10-08T08:11:47.088Z
Learning: Test snapshot files in the router-generator tests directory (e.g., files matching the pattern `packages/router-generator/tests/generator/**/routeTree*.snapshot.ts` or `routeTree*.snapshot.js`) should not be modified or have issues flagged, as they are fixtures used to verify the generator's output and are intentionally preserved as-is.

Applied to files:

  • e2e/solid-start/basic-solid-query/src/routes/test-head/dashboard.tsx
  • e2e/solid-start/basic-solid-query/src/routes/test-head/article.$id.tsx
  • e2e/solid-start/basic-solid-query/src/routeTree.gen.ts
📚 Learning: 2025-10-01T18:31:35.420Z
Learnt from: schiller-manuel
Repo: TanStack/router PR: 5330
File: e2e/react-start/custom-basepath/src/routeTree.gen.ts:58-61
Timestamp: 2025-10-01T18:31:35.420Z
Learning: Do not review files named `routeTree.gen.ts` in TanStack Router repositories, as these are autogenerated files that should not be manually modified.

Applied to files:

  • e2e/solid-start/basic-solid-query/src/routes/test-head/dashboard.tsx
  • e2e/solid-start/basic-solid-query/src/routeTree.gen.ts
📚 Learning: 2025-12-06T15:03:07.223Z
Learnt from: CR
Repo: TanStack/router PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-06T15:03:07.223Z
Learning: Use file-based routing in `src/routes/` directories or code-based routing with route definitions

Applied to files:

  • e2e/solid-start/basic-solid-query/src/routes/test-head/dashboard.tsx
  • e2e/solid-start/basic-solid-query/src/routeTree.gen.ts
📚 Learning: 2025-11-02T16:16:24.898Z
Learnt from: nlynzaad
Repo: TanStack/router PR: 5732
File: packages/start-client-core/src/client/hydrateStart.ts:6-9
Timestamp: 2025-11-02T16:16:24.898Z
Learning: In packages/start-client-core/src/client/hydrateStart.ts, the `import/no-duplicates` ESLint disable is necessary for imports from `#tanstack-router-entry` and `#tanstack-start-entry` because both aliases resolve to the same placeholder file (`fake-start-entry.js`) in package.json during static analysis, even though they resolve to different files at runtime.

Applied to files:

  • e2e/solid-start/basic-solid-query/src/routes/fake-login.tsx
  • e2e/solid-start/basic-solid-query/src/routeTree.gen.ts
📚 Learning: 2025-10-09T12:59:02.129Z
Learnt from: hokkyss
Repo: TanStack/router PR: 5418
File: e2e/react-start/custom-identifier-prefix/src/styles/app.css:19-21
Timestamp: 2025-10-09T12:59:02.129Z
Learning: In e2e test directories (paths containing `e2e/`), accessibility concerns like outline suppression patterns are less critical since the code is for testing purposes, not production use.

Applied to files:

  • e2e/solid-start/basic-solid-query/tests/head.spec.ts
📚 Learning: 2025-12-17T02:17:55.086Z
Learnt from: schiller-manuel
Repo: TanStack/router PR: 6120
File: packages/router-generator/src/generator.ts:654-657
Timestamp: 2025-12-17T02:17:55.086Z
Learning: In `packages/router-generator/src/generator.ts`, pathless_layout routes must receive a `path` property when they have a `cleanedPath`, even though they are non-path routes. This is necessary because child routes inherit the path from their parent, and without this property, child routes would not have the correct full path at runtime.

Applied to files:

  • e2e/solid-start/basic-solid-query/src/routeTree.gen.ts
📚 Learning: 2025-12-21T12:52:35.231Z
Learnt from: Sheraff
Repo: TanStack/router PR: 6171
File: packages/router-core/src/new-process-route-tree.ts:898-898
Timestamp: 2025-12-21T12:52:35.231Z
Learning: In `packages/router-core/src/new-process-route-tree.ts`, the matching logic intentionally allows paths without trailing slashes to match index routes with trailing slashes (e.g., `/a` can match `/a/` route), but not vice-versa (e.g., `/a/` cannot match `/a` layout route). This is implemented via the condition `!pathIsIndex || node.kind === SEGMENT_TYPE_INDEX` and is a deliberate design decision to provide better UX by being permissive with missing trailing slashes.

Applied to files:

  • e2e/solid-start/basic-solid-query/src/routeTree.gen.ts
📚 Learning: 2025-12-06T15:03:07.223Z
Learnt from: CR
Repo: TanStack/router PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-06T15:03:07.223Z
Learning: Applies to **/*.{js,ts,tsx} : Implement ESLint rules for router best practices using the ESLint plugin router

Applied to files:

  • e2e/solid-start/basic-solid-query/src/routeTree.gen.ts
🧬 Code graph analysis (3)
e2e/solid-start/basic-solid-query/src/routes/fake-login.tsx (1)
e2e/solid-start/basic-solid-query/src/utils/fake-auth.ts (1)
  • authQy (8-13)
e2e/solid-start/basic-solid-query/src/routes/test-head/article.$id.tsx (2)
e2e/solid-start/basic-solid-query/src/utils/fake-auth.ts (2)
  • isAuthed (4-6)
  • authQy (8-13)
e2e/solid-start/basic-solid-query/src/routes/fake-login.tsx (1)
  • Route (5-11)
e2e/solid-start/basic-solid-query/src/routeTree.gen.ts (2)
e2e/react-start/basic-react-query/src/routeTree.gen.ts (4)
  • FileRoutesByTo (138-153)
  • FileRoutesById (154-174)
  • FileRouteTypes (175-231)
  • RootRouteChildren (232-243)
e2e/solid-start/query-integration/src/routeTree.gen.ts (4)
  • FileRoutesByTo (37-41)
  • FileRoutesById (42-47)
  • FileRouteTypes (48-55)
  • RootRouteChildren (56-60)
⏰ 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 (8)
e2e/solid-start/basic-solid-query/package.json (1)

11-11: LGTM!

Using pnpm dlx instead of pnpx is the correct and modern approach for executing packages with pnpm.

e2e/solid-start/basic-solid-query/src/routes/test-head/dashboard.tsx (1)

1-45: LGTM!

Well-structured test route with proper route definition, static head metadata, and appropriate data-testid attributes for Playwright tests. The instructional content serves as useful documentation for manual testing of the fix.

e2e/solid-start/basic-solid-query/src/utils/fake-auth.ts (1)

1-13: LGTM!

Clean implementation of client-only auth utilities. Using createClientOnlyFn ensures SSR safety for localStorage access, and the query configuration with disabled refetchOnWindowFocus is appropriate for stable e2e testing.

e2e/solid-start/basic-solid-query/tests/head.spec.ts (2)

3-8: Good test isolation setup.

Clearing localStorage before each test ensures tests don't affect each other. The navigation to / first ensures the page context is established before clearing storage.


10-48: Excellent coverage of the core bug scenario.

This test precisely reproduces the issue described in #6221: the back navigation after login should update the page title from the stale "Article Not Found" to the correct article title. The step-by-step comments make the test intention clear.

e2e/solid-start/basic-solid-query/src/routes/fake-login.tsx (1)

1-43: LGTM!

Well-implemented login simulation route. Key design choices are sound:

  • SSR disabled for client-only auth state
  • Query invalidation ensures auth cache is refreshed
  • replace: true removes login from history stack, enabling the back-button test scenario
  • Consistent use of authQy.queryKey for query invalidation
e2e/solid-start/basic-solid-query/src/routes/test-head/article.$id.tsx (2)

6-21: Solid test implementation.

The simulated async loader with authentication gating correctly reproduces the stale-while-revalidate scenario. The 200ms delay ensures the async timing behavior is exercised.


41-86: Good component structure for testing both states.

The use of Show for conditional rendering based on auth query state, combined with separate loader data access, correctly exercises both the head() timing and UI rendering paths. The logout mechanism using window.location.reload() ensures a clean state reset for test verification.

Fixes race condition where async loader's finally block executes after
navigation has changed, causing match to be undefined when accessing
_nonReactive properties.

This resolves 11 unhandled rejection errors in unit tests:
- TypeError: Cannot read properties of undefined (reading '_nonReactive')
- All errors pointed to load-matches.ts:851 in async callback's finally block

The fix adds a null check before accessing match._nonReactive to handle
cases where the match has been removed from the router during async execution.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
Copy link
Contributor

@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 (2)
packages/router-core/src/load-matches.ts (2)

586-605: Consider using router-specific error handling.

The function logs errors using console.error, which may not integrate with the router's error handling or logging mechanisms. While this ensures errors don't halt head execution for other routes, consider whether these errors should be:

  • Stored in match state for observability
  • Reported through a router-level error handler
  • Surfaced differently in development vs production

</comment_end>


960-960: Strengthen type safety of the filter operation.

The .filter(Boolean) removes falsy values but could be more explicit for TypeScript strict mode compliance.

🔎 Proposed type-safe filter
-      .filter(Boolean)
+      .filter((p): p is NonNullable<typeof p> => p !== null && p !== undefined)

Or more concisely with a type predicate:

-      .filter(Boolean)
+      .filter((p): p is Exclude<typeof p, null | undefined> => Boolean(p))
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5914d25 and 2687842.

📒 Files selected for processing (1)
  • packages/router-core/src/load-matches.ts
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Use TypeScript strict mode with extensive type safety for all code

Files:

  • packages/router-core/src/load-matches.ts
**/*.{js,ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Implement ESLint rules for router best practices using the ESLint plugin router

Files:

  • packages/router-core/src/load-matches.ts
⏰ 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/load-matches.ts (2)

846-857: Good defensive fix for navigation race condition.

The null check properly handles the edge case where a match might be removed during async loader execution due to navigation changes. This prevents the TypeError mentioned in the commit message and ensures promises resolve correctly to allow head() execution.

</comment_end>


955-973: Location guard via reference equality correctly prevents stale head() execution in invalidation scenarios.

The concern raised about location comparison being insufficient for invalidation is unfounded. When invalidate() is called on the same route:

  1. load()beforeLoad()updateLatestLocation() creates a new ParsedLocation object reference each time via parseLocation()
  2. The new reference is stored in state.location
  3. The previously scheduled head() from the earlier loadMatches compares state.location === thisNavigationLocation using reference equality
  4. Since a new ParsedLocation reference was created, the comparison fails (B !== A), correctly preventing stale execution
  5. A fresh loadMatches invocation runs with the new location reference

This design correctly handles all invalidation scenarios without requiring additional generation counters. The reference equality check is sufficient because new location objects are always created on each load cycle.

Moving the promise resolution from finally block back to try block.
This ensures promises are only resolved on successful loader completion,
not on errors or redirects.

Resolving on redirect was incorrect because we're navigating away from
the route, so head() re-execution for the old route doesn't make sense.

The null check is kept as a safety measure since this code still runs
in an async callback where navigation could theoretically change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
Copy link
Contributor

@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

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2687842 and 4b1a9dd.

📒 Files selected for processing (1)
  • packages/router-core/src/load-matches.ts
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Use TypeScript strict mode with extensive type safety for all code

Files:

  • packages/router-core/src/load-matches.ts
**/*.{js,ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Implement ESLint rules for router best practices using the ESLint plugin router

Files:

  • packages/router-core/src/load-matches.ts
⏰ 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/load-matches.ts (2)

586-605: LGTM: Centralizes head execution with proper error isolation.

The new executeAllHeadFns function correctly serializes head execution across all matches and ensures that errors in one head function don't prevent others from running. The per-match try-catch with logging is a solid pattern.


842-848: LGTM: Good defensive guard against navigation race conditions.

This null check correctly handles the case where navigation changes while an async loader is running, preventing errors when attempting to resolve promises on a match that no longer exists. This is consistent with similar guards elsewhere in the codebase (e.g., line 550-554 in executeHead).

@schiller-manuel
Copy link
Contributor

can you please move this into e2e/react-start/basic?
this issue should not require query

This commit adds comprehensive null checks to prevent crashes when match
is undefined due to navigation changes during async operations.

Two critical fixes:

1. Finally block for async loaders (lines 846-855):
   - Ensures promises always settle (success, error, or redirect)
   - Required for Promise.allSettled() to complete properly
   - Enables correct store updates during navigation
   - Location check (line 969) prevents stale head() execution

2. Null check after async callback (lines 860-871):
   - Protects clearTimeout and other _nonReactive accesses
   - Hypothesis: The original 11 errors were from line 866, not the async callback
   - This line was using getMatch(matchId)! without protection

Root cause: When navigation changes during async loader execution,
getMatch(matchId) returns undefined, causing crashes when accessing
_nonReactive properties.

Fixes:
- 11 unhandled rejection errors (TypeError accessing _nonReactive)
- 2 test failures (link test + store updates with redirect)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
Copy link
Contributor

@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 (1)
packages/router-core/src/load-matches.ts (1)

962-972: Critical issues with scheduled head re-execution remain unaddressed.

The scheduled head re-execution has two critical problems that were flagged in previous reviews:

  1. Location comparison is insufficient: The guard latestLocation === thisNavigationLocation fails to detect new load cycles when router.invalidate() is called on the same route. The location remains unchanged during invalidation, but a new load cycle begins with fresh loaders. This allows stale scheduled executions to overwrite fresher head data.

  2. Missing error handler: The Promise.allSettled().then() chain lacks a .catch() handler, creating potential unhandled promise rejections if executeAllHeadFns throws or rejects.

Additional concerns:

  • No stored reference to the scheduled promise, so if a new loadMatches cycle starts before this completes, there's no way to cancel the stale execution.
  • Multiple rapid invalidations at the same location can cause older scheduled executions to overwrite newer data.
🔎 Recommended fixes

Solution 1: Track load cycle with a unique identifier

Consider adding a unique load cycle identifier (e.g., incrementing counter or UUID) to InnerLoadContext and capture it when scheduling:

+  const thisLoadCycleId = inner.router.state.loadCycleId // or generate UUID
   const thisNavigationLocation = inner.location
   Promise.allSettled(asyncLoaderPromises).then(() => {
-    const latestLocation = inner.router.state.location
-    if (latestLocation === thisNavigationLocation) {
+    const isStillCurrent = inner.router.state.loadCycleId === thisLoadCycleId
+    if (isStillCurrent) {
       executeAllHeadFns(inner)
     }
+  }).catch((err) => {
+    inner.router.options.logger?.error('Error in scheduled head re-execution:', err)
   })

Solution 2: Store and cancel stale executions

Store a reference to the scheduled promise in router state and cancel it when a new load starts:

+  // Cancel any pending head re-execution from previous load
+  inner.router.state.pendingHeadReExecution?.cancel()
+
   const thisNavigationLocation = inner.location
-  Promise.allSettled(asyncLoaderPromises).then(() => {
+  const scheduledExecution = Promise.allSettled(asyncLoaderPromises).then(() => {
     const latestLocation = inner.router.state.location
     if (latestLocation === thisNavigationLocation) {
       executeAllHeadFns(inner)
     }
+  }).catch((err) => {
+    inner.router.options.logger?.error('Error in scheduled head re-execution:', err)
   })
+  
+  inner.router.state.pendingHeadReExecution = {
+    promise: scheduledExecution,
+    cancel: () => { /* set flag to skip execution */ }
+  }

At minimum, add the .catch() handler to prevent unhandled rejections.

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

586-605: Consider using router's logger instead of console.error.

The error logging uses console.error, but the router likely has a configured logger (e.g., inner.router.options.logger) that should be preferred for consistency with the rest of the codebase.

🔎 Proposed refactor
    } catch (err) {
-     console.error(`Error executing head for route ${routeId}:`, err)
+     inner.router.options.logger?.error(`Error executing head for route ${routeId}:`, err)
    }
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4b1a9dd and 90d4ba3.

📒 Files selected for processing (1)
  • packages/router-core/src/load-matches.ts
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Use TypeScript strict mode with extensive type safety for all code

Files:

  • packages/router-core/src/load-matches.ts
**/*.{js,ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Implement ESLint rules for router best practices using the ESLint plugin router

Files:

  • packages/router-core/src/load-matches.ts
🧠 Learnings (2)
📚 Learning: 2025-10-01T18:30:26.591Z
Learnt from: schiller-manuel
Repo: TanStack/router PR: 5330
File: packages/router-core/src/router.ts:2231-2245
Timestamp: 2025-10-01T18:30:26.591Z
Learning: In `packages/router-core/src/router.ts`, the `resolveRedirect` method intentionally strips the router's origin from redirect URLs when they match (e.g., `https://foo.com/bar` → `/bar` for same-origin redirects) while preserving the full URL for cross-origin redirects. This logic should not be removed or simplified to use `location.publicHref` directly.

Applied to files:

  • packages/router-core/src/load-matches.ts
📚 Learning: 2025-10-08T08:11:47.088Z
Learnt from: nlynzaad
Repo: TanStack/router PR: 5402
File: packages/router-generator/tests/generator/no-formatted-route-tree/routeTree.nonnested.snapshot.ts:19-21
Timestamp: 2025-10-08T08:11:47.088Z
Learning: Test snapshot files in the router-generator tests directory (e.g., files matching the pattern `packages/router-generator/tests/generator/**/routeTree*.snapshot.ts` or `routeTree*.snapshot.js`) should not be modified or have issues flagged, as they are fixtures used to verify the generator's output and are intentionally preserved as-is.

Applied to files:

  • packages/router-core/src/load-matches.ts
⏰ 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 (2)
packages/router-core/src/load-matches.ts (2)

846-855: Good defensive coding for navigation edge cases.

The null check for match before resolving promises properly handles the scenario where navigation changes while an async loader is running, preventing crashes when matches become undefined.


862-873: LGTM: Null-safe access pattern.

The null check for match before accessing its properties is consistent with the defensive pattern introduced in the finally block and appropriately guards against navigation changes.

The null check at line 862 was incorrect. That code runs synchronously
(doesn't wait for async loader), so match exists there. Only the async
callback's finally block needs null check for race conditions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
Copy link
Contributor

@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 (1)
packages/router-core/src/load-matches.ts (1)

963-970: Location comparison cannot detect same-route invalidation.

As noted in previous review discussions, comparing location objects is insufficient because router.invalidate() on the same route triggers new loaders without changing the location. This allows stale scheduled head executions to proceed and potentially overwrite fresher data.

Scenario: User navigates to /article/123, async loaders start, then router.invalidate() is called before loaders complete. New loaders start but thisNavigationLocation === latestLocation still passes, allowing the old scheduled execution to run with stale data.

Without a stored reference to the scheduled promise or a more robust navigation tracking mechanism (e.g., using a generation ID or load cycle counter), multiple invalidations on the same route can cause older executions to overwrite newer ones.

Consider one of these approaches:

  1. Introduce a per-load-cycle identifier (generation counter) and capture it alongside the location
  2. Store a reference to the scheduled promise and replace it if new loaders start
  3. Check if the captured asyncLoaderPromises are still the current ones before re-executing heads

Based on learnings, the router may already track navigation state that could be leveraged here.

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

948-971: Tests are needed to verify the fix.

As discussed in the PR comments, tests are required for this change. The maintainer requested moving the e2e test from e2e/solid-start/test-head-bug into e2e/react-start/basic with Playwright tests covering the reproduction scenario (navigate to authenticated article route, lose auth and reload, log in and redirect, use Back button to return to article).

Do you need help writing the Playwright test for this scenario, or would you like me to open a separate issue to track the test addition?

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 90d4ba3 and 2e8a34b.

📒 Files selected for processing (1)
  • packages/router-core/src/load-matches.ts
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Use TypeScript strict mode with extensive type safety for all code

Files:

  • packages/router-core/src/load-matches.ts
**/*.{js,ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Implement ESLint rules for router best practices using the ESLint plugin router

Files:

  • packages/router-core/src/load-matches.ts
🧠 Learnings (2)
📚 Learning: 2025-10-01T18:30:26.591Z
Learnt from: schiller-manuel
Repo: TanStack/router PR: 5330
File: packages/router-core/src/router.ts:2231-2245
Timestamp: 2025-10-01T18:30:26.591Z
Learning: In `packages/router-core/src/router.ts`, the `resolveRedirect` method intentionally strips the router's origin from redirect URLs when they match (e.g., `https://foo.com/bar` → `/bar` for same-origin redirects) while preserving the full URL for cross-origin redirects. This logic should not be removed or simplified to use `location.publicHref` directly.

Applied to files:

  • packages/router-core/src/load-matches.ts
📚 Learning: 2025-10-08T08:11:47.088Z
Learnt from: nlynzaad
Repo: TanStack/router PR: 5402
File: packages/router-generator/tests/generator/no-formatted-route-tree/routeTree.nonnested.snapshot.ts:19-21
Timestamp: 2025-10-08T08:11:47.088Z
Learning: Test snapshot files in the router-generator tests directory (e.g., files matching the pattern `packages/router-generator/tests/generator/**/routeTree*.snapshot.ts` or `routeTree*.snapshot.js`) should not be modified or have issues flagged, as they are fixtures used to verify the generator's output and are intentionally preserved as-is.

Applied to files:

  • packages/router-core/src/load-matches.ts
⏰ 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 (3)
packages/router-core/src/load-matches.ts (3)

586-605: Well-structured serial head execution with proper error isolation.

The executeAllHeadFns function correctly executes head functions serially for all matches, with per-match try-catch blocks ensuring one failure doesn't block others. This approach prevents race conditions when updating match data.


846-855: Critical fix: Ensures loader promises always resolve.

The finally block correctly handles the race condition where a match may become undefined during navigation while async loaders are running. Always resolving the promises ensures Promise.allSettled workflows can complete without hanging, which is essential for the scheduled head re-execution to work correctly.


948-949: LGTM: Initial head execution after loaders complete.

Executing head functions after all loaders have settled (successfully or not) ensures the initial render has the most complete data available at that point.

Comment on lines +960 to +970
if (asyncLoaderPromises.length > 0) {
// Schedule re-execution after all async loaders complete (non-blocking)
// Use allSettled to handle both successful and failed loaders
const thisNavigationLocation = inner.location
Promise.allSettled(asyncLoaderPromises).then(() => {
// Only execute if this navigation is still current (not superseded by new navigation)
const latestLocation = inner.router.state.location
if (latestLocation === thisNavigationLocation) {
executeAllHeadFns(inner)
}
} catch (err) {
// Log error but continue executing other head functions
console.error(`Error executing head for route ${routeId}:`, err)
}
})
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Missing error handler risks unhandled promise rejection.

The detached promise chain (lines 964-970) lacks a .catch() handler. If executeAllHeadFns throws or returns a rejected promise during the scheduled re-execution, this will produce an unhandled rejection. Past review comments indicated this was addressed in commit 90d4ba3, but the error handler is not present in the current code.

🔎 Add error handling to prevent unhandled rejections
       const thisNavigationLocation = inner.location
       Promise.allSettled(asyncLoaderPromises).then(() => {
         // Only execute if this navigation is still current (not superseded by new navigation)
         const latestLocation = inner.router.state.location
         if (latestLocation === thisNavigationLocation) {
           executeAllHeadFns(inner)
         }
-      })
+      }).catch((err) => {
+        console.error('Error in scheduled head re-execution:', err)
+      })
     }
📝 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
if (asyncLoaderPromises.length > 0) {
// Schedule re-execution after all async loaders complete (non-blocking)
// Use allSettled to handle both successful and failed loaders
const thisNavigationLocation = inner.location
Promise.allSettled(asyncLoaderPromises).then(() => {
// Only execute if this navigation is still current (not superseded by new navigation)
const latestLocation = inner.router.state.location
if (latestLocation === thisNavigationLocation) {
executeAllHeadFns(inner)
}
} catch (err) {
// Log error but continue executing other head functions
console.error(`Error executing head for route ${routeId}:`, err)
}
})
if (asyncLoaderPromises.length > 0) {
// Schedule re-execution after all async loaders complete (non-blocking)
// Use allSettled to handle both successful and failed loaders
const thisNavigationLocation = inner.location
Promise.allSettled(asyncLoaderPromises).then(() => {
// Only execute if this navigation is still current (not superseded by new navigation)
const latestLocation = inner.router.state.location
if (latestLocation === thisNavigationLocation) {
executeAllHeadFns(inner)
}
}).catch((err) => {
console.error('Error in scheduled head re-execution:', err)
})
}
🤖 Prompt for AI Agents
In packages/router-core/src/load-matches.ts around lines 960 to 970, the
detached Promise.allSettled(...).then(...) chain can produce an unhandled
rejection if executeAllHeadFns throws or returns a rejected promise; add a
.catch handler to that promise chain to handle any errors from executeAllHeadFns
(or from the then callback) and prevent unhandled rejections — call the router's
error handler if available (e.g. inner.router.options?.onError or
inner.router.options?.onExecuteError) with the error, otherwise log to
console.error, and ensure the catch swallows or handles the error so the
detached promise is not left unhandled.

Root cause: When loader throws redirect, the catch block calls
inner.router.navigate() which removes the old match from the router.
Then finally block tries getMatch(matchId) but returns undefined,
so promises never resolve, blocking Promise.allSettled.

Solution: Capture match reference BEFORE entering try block, so we
have a stable reference even if redirect removes it from router.

Flow with redirect:
1. Get matchForCleanup (captures reference)
2. runLoader throws redirect
3. Catch: navigate() removes match from router
4. Finally: Use matchForCleanup (still valid) to resolve promises

This allows Promise.allSettled to complete and navigation to proceed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
@yanghuidong
Copy link
Contributor Author

can you please move this into e2e/react-start/basic? this issue should not require query

Sure, but solid-start work as well, right? I don't know react, sorry.
I'm current trying to fix the PR / Test failures, if I'm stuck for longer, might need your help :)

Copy link
Contributor

@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)

959-970: Critical: Add error handler to prevent unhandled promise rejection.

The detached promise chain lacks a .catch() handler. If executeAllHeadFns throws or returns a rejected promise during the scheduled re-execution, this will produce an unhandled rejection. Past review comments indicated this was addressed in commit 90d4ba3, but the error handler is still missing in the current code.

🔎 Add error handling to prevent unhandled rejections
       const thisNavigationLocation = inner.location
       Promise.allSettled(asyncLoaderPromises).then(() => {
         // Only execute if this navigation is still current (not superseded by new navigation)
         const latestLocation = inner.router.state.location
         if (latestLocation === thisNavigationLocation) {
           executeAllHeadFns(inner)
         }
-      })
+      }).catch((err) => {
+        const logger = inner.router.options?.logger ?? console
+        logger.error('Error in scheduled head re-execution:', err)
+      })
     }

962-969: Major: Location comparison insufficient to prevent stale head updates.

When router.invalidate() is called on the same route, it triggers new loaders while keeping the location unchanged. The scheduled head re-execution captures thisNavigationLocation and later compares it against the current location. Since the location doesn't change during invalidation, this guard fails to detect that a new load cycle has begun, allowing stale scheduled executions to overwrite fresh data.

Additionally, there's no stored reference to the scheduled promise, so if a new loadMatches cycle starts before the previous promise completes, there's no way to cancel the stale execution.

Consider one of these approaches:

  • Store a reference to the scheduled execution promise and replace/cancel it if new loaders are detected
  • Use a load-cycle ID or generation counter that increments on each loadMatches call (not just navigation)
  • Check if the captured loader promises are still the current ones before re-executing heads

Verify if the router has a generation ID or load-cycle tracking mechanism that could be used instead of location comparison:

#!/bin/bash
# Search for generation/cycle/load tracking mechanisms in router state
rg -n --type=ts -C3 'generation|loadId|cycleId|navigationId' packages/router-core/src/router.ts

# Check if matches have version/generation tracking
rg -n --type=ts -C3 'generation|version|loadId' packages/router-core/src/Matches.ts
🧹 Nitpick comments (1)
packages/router-core/src/load-matches.ts (1)

586-605: Consider using router's logger instead of console.error.

The executeAllHeadFns function logs errors using console.error directly. If the router has a configured logger (e.g., inner.router.options?.logger), prefer using it for consistency with other error logging throughout the codebase.

🔎 Proposed enhancement
     } catch (err) {
-      console.error(`Error executing head for route ${routeId}:`, err)
+      const logger = inner.router.options?.logger ?? console
+      logger.error(`Error executing head for route ${routeId}:`, err)
     }
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2e8a34b and 5b17c13.

📒 Files selected for processing (1)
  • packages/router-core/src/load-matches.ts
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Use TypeScript strict mode with extensive type safety for all code

Files:

  • packages/router-core/src/load-matches.ts
**/*.{js,ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Implement ESLint rules for router best practices using the ESLint plugin router

Files:

  • packages/router-core/src/load-matches.ts
🧠 Learnings (3)
📚 Learning: 2025-10-08T08:11:47.088Z
Learnt from: nlynzaad
Repo: TanStack/router PR: 5402
File: packages/router-generator/tests/generator/no-formatted-route-tree/routeTree.nonnested.snapshot.ts:19-21
Timestamp: 2025-10-08T08:11:47.088Z
Learning: Test snapshot files in the router-generator tests directory (e.g., files matching the pattern `packages/router-generator/tests/generator/**/routeTree*.snapshot.ts` or `routeTree*.snapshot.js`) should not be modified or have issues flagged, as they are fixtures used to verify the generator's output and are intentionally preserved as-is.

Applied to files:

  • packages/router-core/src/load-matches.ts
📚 Learning: 2025-12-21T12:52:35.231Z
Learnt from: Sheraff
Repo: TanStack/router PR: 6171
File: packages/router-core/src/new-process-route-tree.ts:898-898
Timestamp: 2025-12-21T12:52:35.231Z
Learning: In `packages/router-core/src/new-process-route-tree.ts`, the matching logic intentionally allows paths without trailing slashes to match index routes with trailing slashes (e.g., `/a` can match `/a/` route), but not vice-versa (e.g., `/a/` cannot match `/a` layout route). This is implemented via the condition `!pathIsIndex || node.kind === SEGMENT_TYPE_INDEX` and is a deliberate design decision to provide better UX by being permissive with missing trailing slashes.

Applied to files:

  • packages/router-core/src/load-matches.ts
📚 Learning: 2025-10-01T18:30:26.591Z
Learnt from: schiller-manuel
Repo: TanStack/router PR: 5330
File: packages/router-core/src/router.ts:2231-2245
Timestamp: 2025-10-01T18:30:26.591Z
Learning: In `packages/router-core/src/router.ts`, the `resolveRedirect` method intentionally strips the router's origin from redirect URLs when they match (e.g., `https://foo.com/bar` → `/bar` for same-origin redirects) while preserving the full URL for cross-origin redirects. This logic should not be removed or simplified to use `location.publicHref` directly.

Applied to files:

  • packages/router-core/src/load-matches.ts
⏰ 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)

839-854: Good defensive fix for redirect-induced match removal.

Capturing the match reference before the try block ensures that the finally block can still resolve promises even if a redirect navigation removes the match from the router. This prevents Promise.allSettled from hanging and is a solid improvement to the loader execution flow.


947-948: LGTM: Initial head execution correctly placed.

Executing all head functions after loaders have completed ensures that the initial head data is available with loader results. This addresses the core issue where head() was executing before loaderData was ready.

@yanghuidong
Copy link
Contributor Author

Still getting PR test failures:

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯

 FAIL   @tanstack/solid-router  tests/link.test.tsx > Link > when navigation to . from /posts while updating search from /
Error: expect(element).toHaveTextContent()

Expected element to have text content:
  Page: 2
Received:
  Page: 1
 ❯ tests/link.test.tsx:792:25
    790|     expect(window.location.search).toBe('?page=2&filter=inactive')
    791| 
    792|     expect(updatedPage).toHaveTextContent('Page: 2')
       |                         ^
    793|     expect(updatedFilter).toHaveTextContent('Filter: inactive')
    794|   })

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯

 FAIL   @tanstack/solid-router  tests/store-updates-during-navigation.test.tsx > Store doesn't update *too many* times during navigation > redirection in preload
AssertionError: expected 2 to be 7 // Object.is equality

- Expected
+ Received

- 7
+ 2

 ❯ tests/store-updates-during-navigation.test.tsx:159:21
    157|     // Any change that increases this number should be investigated.
    158|     // Note: Solid has different update counts than React due to diffe…
    159|     expect(updates).toBe(7)
       |                     ^
    160|   })
    161| 

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯


 Test Files  2 failed | 43 passed (45)
      Tests  2 failed | 761 passed | 1 skipped (764)

Wondering if they were caused by introducing the re-run of head functions (Promise.allSettled)?

@schiller-manuel
Copy link
Contributor

yes this is caused by rerunning head. needs to adapt the counts for the scenarios where a detached loader runs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Route head function can still execute before loader function finishes

2 participants