Skip to content

refactor(router-core): rework the conditions under which a beforeLoad is executed or skipped #4993

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: main
Choose a base branch
from

Conversation

Sheraff
Copy link
Contributor

@Sheraff Sheraff commented Aug 18, 2025

WIP

Summary by CodeRabbit

  • Tests
    • Added comprehensive test suites validating preload behavior for route loading hooks and their interaction with navigation.
    • Covers: no preload, preload in progress (skips handler), preload resolving (single invocation), and preload rejecting as not-found, redirect, or error.
    • Strengthens async/timing coverage to ensure predictable transitions, correct control flow, and more reliable navigation for end users.

Copy link

coderabbitai bot commented Aug 18, 2025

Walkthrough

Adds a new test suite at packages/router-core/tests/load.test.ts that verifies RouterCore preload semantics for both beforeLoad and loader hooks across scenarios: no preload, preload pending, preload resolved, and preload rejected (notFound/redirect/error), asserting invocation counts, pending/cached matches, final location, and loader data.

Changes

Cohort / File(s) Summary of Changes
Tests: beforeLoad & loader preload behavior
packages/router-core/tests/load.test.ts
Adds a comprehensive test suite validating RouterCore preload semantics for beforeLoad and loader hooks. Covers baseline (no hook), normal navigation, preloadRoute followed by navigation (pending vs resolved), resolved preload, rejected preload variants (notFound, redirect, generic Error), immediate and pending rejections, and asserts hook call counts, pendingMatches/cachedMatches/matches, final location/path, and loader data/context. Includes a small sleep helper to simulate async delays.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor Test
  participant RouterCore
  participant Route.beforeLoad
  participant Route.loader

  Note over Test,RouterCore: Preload phase
  Test->>RouterCore: preloadRoute("/foo")
  RouterCore->>Route.beforeLoad: invoke (preload) / reuse pending
  RouterCore->>Route.loader: invoke (preload) / reuse pending
  alt preload resolves
    Route.beforeLoad-->>RouterCore: resolve
    Route.loader-->>RouterCore: resolve(data)
  else preload pending
    Route.beforeLoad-->>RouterCore: (pending)
    Route.loader-->>RouterCore: (pending)
  else preload rejects (notFound/redirect/error)
    Route.beforeLoad-->>RouterCore: reject(reason)
    Route.loader-->>RouterCore: reject(reason)
  end

  Note over Test,RouterCore: Navigation phase
  Test->>RouterCore: navigate("/foo")
  alt preload pending
    RouterCore-->>RouterCore: use pending/cached state, avoid duplicate invocation
  else preload resolved or none
    RouterCore->>Route.beforeLoad: invoke (navigation)
    Route.beforeLoad-->>RouterCore: resolve/reject
    RouterCore->>Route.loader: invoke (navigation)
    Route.loader-->>RouterCore: resolve(data)/reject
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested reviewers

  • schiller-manuel

Poem

I hopped through tests with whiskers bright,
Preloads pending, resolved, and slight,
I chased each error, redirect, and thunk,
Nibbling edge cases, then did a little dunk. 🥕🐇


📜 Recent 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 67c7e04 and 2a291e0.

📒 Files selected for processing (1)
  • packages/router-core/tests/load.test.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/router-core/tests/load.test.ts
✨ 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-before-load-skip-or-execute

🪧 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 18, 2025

View your CI Pipeline Execution ↗ for commit 2a291e0

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

☁️ Nx Cloud last updated this comment at 2025-08-23 10:58:49 UTC

Copy link

pkg-pr-new bot commented Aug 18, 2025

More templates

@tanstack/arktype-adapter

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

@tanstack/directive-functions-plugin

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

@tanstack/eslint-plugin-router

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

@tanstack/history

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

@tanstack/react-router

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

@tanstack/react-router-devtools

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

@tanstack/react-router-ssr-query

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

@tanstack/react-start

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

@tanstack/react-start-client

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

@tanstack/react-start-plugin

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

@tanstack/react-start-server

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

@tanstack/router-cli

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

@tanstack/router-core

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

@tanstack/router-devtools

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

@tanstack/router-devtools-core

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

@tanstack/router-generator

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

@tanstack/router-plugin

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

@tanstack/router-ssr-query-core

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

@tanstack/router-utils

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

@tanstack/router-vite-plugin

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

@tanstack/server-functions-plugin

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

@tanstack/solid-router

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

@tanstack/solid-router-devtools

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

@tanstack/solid-start

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

@tanstack/solid-start-client

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

@tanstack/solid-start-plugin

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

@tanstack/solid-start-server

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

@tanstack/start-client-core

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

@tanstack/start-plugin-core

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

@tanstack/start-server-core

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

@tanstack/start-server-functions-client

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

@tanstack/start-server-functions-fetcher

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

@tanstack/start-server-functions-server

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

@tanstack/start-storage-context

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

@tanstack/valibot-adapter

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

@tanstack/virtual-file-routes

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

@tanstack/zod-adapter

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

commit: 2a291e0

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 (3)
packages/router-core/tests/load.test.ts (3)

7-9: Avoid real 100ms timers; make the test deterministic with fake timers

Using setTimeout(100) slows the suite and can be flaky in CI. You can keep the same behavior while making timing deterministic by switching to Vitest fake timers and flushing them.

Apply this diff to control timers and avoid real sleeps:

   test('current behavior', async () => {
-    const rootRoute = new BaseRootRoute({})
-    const beforeLoad = vi.fn(
-      () => new Promise((resolve) => setTimeout(resolve, 100)),
-    )
+    vi.useFakeTimers()
+    const rootRoute = new BaseRootRoute({})
+    const beforeLoad = vi.fn(
+      () => new Promise((resolve) => setTimeout(resolve, 100)),
+    )

And later in the test, flush timers and then restore real timers:

-    await router.navigate({ to: '/foo' })
+    const navPromise = router.navigate({ to: '/foo' })
+    await vi.runAllTimersAsync() // or: await vi.advanceTimersByTimeAsync(100)
+    await navPromise
@@
-    expect(beforeLoad).toHaveBeenCalledTimes(2)
+    expect(beforeLoad).toHaveBeenCalledTimes(2)
+    vi.useRealTimers()

5-5: Rename test for clarity

“current behavior” is vague. Make the assertion intent obvious in the title so failures are easier to interpret.

-  test('current behavior', async () => {
+  test('documents current behavior: beforeLoad runs once on preload and once on navigate', async () => {

19-21: Lock in preload behavior with an explicit preload assertion

Adding a quick assertion after the microtask yield makes the test’s intent clear and guards against scheduling changes in preloadRoute. In packages/router-core/tests/load.test.ts around lines 19–21:

     router.preloadRoute({ to: '/foo' })
     await Promise.resolve()
+    expect(beforeLoad).toHaveBeenCalledTimes(1)  // preload should have started exactly once
     // navigation should trigger a second load
     await router.navigate({ to: '/foo' })

Since preloadRoute is async (it returns a Promise), you can optionally add a comment noting that you’re intentionally not awaiting it to test overlapping preload vs. navigation behavior.

📜 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 f2190d7 and 98b5c3a.

📒 Files selected for processing (1)
  • packages/router-core/tests/load.test.ts (1 hunks)
⏰ 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/tests/load.test.ts (2)

4-24: Test structure LGTM; clearly codifies behavior under discussion

Nicely scoped, minimal reproduction that captures the preload vs navigate semantics of beforeLoad.


16-18: No teardown needed for RouterCore in this test

RouterCore in the Node‐based load test uses an in‐memory history and does not register any DOM or background listeners, so there’s nothing to clean up between tests. You can safely omit a .destroy()/.dispose() call here.

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 (6)
packages/router-core/tests/load.test.ts (6)

52-62: Make the “preload is ongoing” test deterministic with fake timers

This test relies on a real 100ms timer to keep preload pending. Real timers can cause flakiness and slow CI. Prefer fake timers to control timing precisely and avoid waiting.

Suggested change within this test:

-  test('skip if preload is ongoing', async () => {
-    const beforeLoad = vi.fn(
+  test('skip if preload is ongoing', async () => {
+    vi.useFakeTimers()
+    const beforeLoad = vi.fn(
       () => new Promise((resolve) => setTimeout(resolve, 100)),
     )
     const router = setup({ beforeLoad })
     router.preloadRoute({ to: '/foo' })
     await Promise.resolve()
     await router.navigate({ to: '/foo' })
 
     expect(beforeLoad).toHaveBeenCalledTimes(1)
+    vi.useRealTimers()
   })

Please run the suite locally once with and once without the change to confirm flake reduction and parity in behavior.


64-71: Resolved preload skip case is clear and sufficient

This ensures successful preloads are reused. Minor nit: using mockResolvedValue(undefined) is slightly more idiomatic, but not required.

Apply if desired:

-    const beforeLoad = vi.fn(() => Promise.resolve())
+    const beforeLoad = vi.fn<BeforeLoad>().mockResolvedValue(undefined)

87-101: Deterministic timing for “pending then reject with notFound”

Same timing concern as the earlier “ongoing” case. Use fake timers so the preload remains pending until you explicitly advance or end the test.

Suggested change within this test:

-  test('exec if preload is pending, but will reject w/ notFound', async () => {
-    const beforeLoad = vi.fn<BeforeLoad>(async ({ preload }) => {
+  test('exec if preload is pending, but will reject w/ notFound', async () => {
+    vi.useFakeTimers()
+    const beforeLoad = vi.fn<BeforeLoad>(async ({ preload }) => {
       await new Promise((resolve) => setTimeout(resolve, 100))
       if (preload) throw notFound()
     })
     const router = setup({
       beforeLoad,
     })
     router.preloadRoute({ to: '/foo' })
     await Promise.resolve()
     await router.navigate({ to: '/foo' })
 
-    expect(beforeLoad).toHaveBeenCalledTimes(2)
+    expect(router.state.location.pathname).toBe('/foo')
+    expect(beforeLoad).toHaveBeenCalledTimes(2)
+    vi.useRealTimers()
   })

117-131: Use fake timers for the “pending then redirect” case

Mirror the deterministic-timers approach here as well.

Suggested change within this test:

-  test('exec if preload is pending, but will reject w/ redirect', async () => {
-    const beforeLoad = vi.fn<BeforeLoad>(async ({ preload }) => {
+  test('exec if preload is pending, but will reject w/ redirect', async () => {
+    vi.useFakeTimers()
+    const beforeLoad = vi.fn<BeforeLoad>(async ({ preload }) => {
       await new Promise((resolve) => setTimeout(resolve, 100))
       if (preload) throw redirect({ to: '/bar' })
     })
@@
-    expect(router.state.location.pathname).toBe('/foo')
+    expect(router.state.location.pathname).toBe('/foo')
     expect(beforeLoad).toHaveBeenCalledTimes(2)
+    vi.useRealTimers()
   })

133-146: Consider asserting the resulting location for the “regular Error” case

For consistency with redirect tests (and to ensure preload errors don’t pollute the subsequent navigation), you could assert pathname === '/foo' here as well.

Optional addition:

     await router.navigate({ to: '/foo' })
 
-    expect(beforeLoad).toHaveBeenCalledTimes(2)
+    expect(router.state.location.pathname).toBe('/foo')
+    expect(beforeLoad).toHaveBeenCalledTimes(2)

Also, confirm that preloadRoute resolves even when beforeLoad throws a generic Error (not just notFound/redirect), since you are awaiting it here.


147-160: Make the “pending then regular Error” test deterministic with fake timers

Same timer determinism recommendation applies here.

Suggested change within this test:

-  test('exec if preload is pending, but will reject w/ regular Error', async () => {
-    const beforeLoad = vi.fn<BeforeLoad>(async ({ preload }) => {
+  test('exec if preload is pending, but will reject w/ regular Error', async () => {
+    vi.useFakeTimers()
+    const beforeLoad = vi.fn<BeforeLoad>(async ({ preload }) => {
       await new Promise((resolve) => setTimeout(resolve, 100))
       if (preload) throw new Error('error')
     })
@@
-    expect(beforeLoad).toHaveBeenCalledTimes(2)
+    expect(router.state.location.pathname).toBe('/foo')
+    expect(beforeLoad).toHaveBeenCalledTimes(2)
+    vi.useRealTimers()
   })
📜 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 98b5c3a and c729162.

📒 Files selected for processing (1)
  • packages/router-core/tests/load.test.ts (1 hunks)
🔇 Additional comments (5)
packages/router-core/tests/load.test.ts (5)

14-36: Solid, minimal test harness setup

Route tree + RouterCore construction is clear and keeps tests focused on beforeLoad behavior. The typed BeforeLoad alias is a nice touch to keep mock signatures aligned.


38-44: Baseline “no-op” case covered well

This verifies that merely loading the router doesn’t spuriously invoke child route beforeLoad. Good guard-rail.


45-50: Happy-path navigation coverage looks good

Single invocation on direct navigation to /foo is the expected baseline.


102-116: Redirect-from-preload behavior validated

Great to see the explicit assertion that a redirect thrown during preload does not hijack the subsequent navigation. This test clearly encodes the intended contract.


73-86: Confirm preloadRoute resolution semantics on rejection paths

I’m not seeing the preloadRoute implementation in the core router—could you please verify whether it’s intended to resolve (and cache) even when beforeLoad throws notFound()? If that is the designed behavior, consider:

– Documenting this contract on RouterCore.preloadRoute.
– Strengthening the test by asserting the location remains /foo after navigation:

 await router.navigate({ to: '/foo' })
 expect(beforeLoad).toHaveBeenCalledTimes(2)
+expect(router.state.location.pathname).toBe('/foo')

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 (7)
packages/router-core/tests/load.test.ts (7)

11-13: Prefer unknown over any for tighter typings.

This keeps the test type-safe while still generic.

-type AnyRouteOptions = RouteOptions<any>
+type AnyRouteOptions = RouteOptions<unknown>
 type BeforeLoad = NonNullable<AnyRouteOptions['beforeLoad']>

68-81: Make timer-driven test deterministic and faster with fake timers.

Relying on real setTimeout(100) can lead to flakiness and slows CI. Use Vitest fake timers and flush them explicitly. Also, ensure you flush the pending timer at the end so no timers leak across tests.

   test('skip if preload is ongoing', async () => {
     const beforeLoad = vi.fn(
       () => new Promise((resolve) => setTimeout(resolve, 100)),
     )
     const router = setup({ beforeLoad })
-    router.preloadRoute({ to: '/foo' })
+    vi.useFakeTimers()
+    router.preloadRoute({ to: '/foo' })
     await Promise.resolve()
     expect(router.state.cachedMatches).toEqual(
       expect.arrayContaining([expect.objectContaining({ id: '/foo' })]),
     )
     await router.navigate({ to: '/foo' })
 
     expect(beforeLoad).toHaveBeenCalledTimes(1)
+    await vi.advanceTimersByTimeAsync(100)
+    vi.useRealTimers()
   })

95-107: Assert final location to ensure preload notFound doesn’t affect real navigation.

This strengthens the contract you’re testing: preload failure with notFound should not prevent a subsequent successful navigation.

   await router.preloadRoute({ to: '/foo' })
   await router.navigate({ to: '/foo' })
 
+  expect(router.state.location.pathname).toBe('/foo')
   expect(beforeLoad).toHaveBeenCalledTimes(2)

109-122: Deterministic pending-reject (notFound) test + assert final location.

Use fake timers to avoid leaking a 100ms pending timer into other tests and explicitly assert that the real navigation is unaffected.

   test('exec if preload is pending, but will reject w/ notFound', async () => {
     const beforeLoad = vi.fn<BeforeLoad>(async ({ preload }) => {
       await new Promise((resolve) => setTimeout(resolve, 100))
       if (preload) throw notFound()
     })
     const router = setup({
       beforeLoad,
     })
-    router.preloadRoute({ to: '/foo' })
+    vi.useFakeTimers()
+    router.preloadRoute({ to: '/foo' })
     await Promise.resolve()
     await router.navigate({ to: '/foo' })
 
+    expect(router.state.location.pathname).toBe('/foo')
     expect(beforeLoad).toHaveBeenCalledTimes(2)
+    await vi.advanceTimersByTimeAsync(100)
+    vi.useRealTimers()
   })

139-153: Use fake timers for pending-reject with redirect to avoid timer leaks.

Same rationale as other timer-based tests. This keeps tests fast and robust.

   test('exec if preload is pending, but will reject w/ redirect', async () => {
     const beforeLoad = vi.fn<BeforeLoad>(async ({ preload }) => {
       await new Promise((resolve) => setTimeout(resolve, 100))
       if (preload) throw redirect({ to: '/bar' })
     })
     const router = setup({
       beforeLoad,
     })
-    router.preloadRoute({ to: '/foo' })
+    vi.useFakeTimers()
+    router.preloadRoute({ to: '/foo' })
     await Promise.resolve()
     await router.navigate({ to: '/foo' })
 
     expect(router.state.location.pathname).toBe('/foo')
     expect(beforeLoad).toHaveBeenCalledTimes(2)
+    await vi.advanceTimersByTimeAsync(100)
+    vi.useRealTimers()
   })

155-167: Add final location assertion to complement call-count check.

Confirms that a preload error doesn’t poison subsequent normal navigation.

   await router.preloadRoute({ to: '/foo' })
   await router.navigate({ to: '/foo' })
 
+  expect(router.state.location.pathname).toBe('/foo')
   expect(beforeLoad).toHaveBeenCalledTimes(2)

169-182: Use fake timers and assert final location for pending-reject with regular Error.

Prevents timer leak and ensures correct behavior on the real navigation.

   test('exec if preload is pending, but will reject w/ regular Error', async () => {
     const beforeLoad = vi.fn<BeforeLoad>(async ({ preload }) => {
       await new Promise((resolve) => setTimeout(resolve, 100))
       if (preload) throw new Error('error')
     })
     const router = setup({
       beforeLoad,
     })
-    router.preloadRoute({ to: '/foo' })
+    vi.useFakeTimers()
+    router.preloadRoute({ to: '/foo' })
     await Promise.resolve()
     await router.navigate({ to: '/foo' })
 
+    expect(router.state.location.pathname).toBe('/foo')
     expect(beforeLoad).toHaveBeenCalledTimes(2)
+    await vi.advanceTimersByTimeAsync(100)
+    vi.useRealTimers()
   })
📜 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 c729162 and f68f3d6.

📒 Files selected for processing (1)
  • packages/router-core/tests/load.test.ts (1 hunks)
⏰ 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 (4)
packages/router-core/tests/load.test.ts (4)

15-36: Route setup helper is clean and reusable.

Good isolation per test and minimal surface area in setup. This makes the suite easy to reason about.


45-66: Solid coverage of "regular nav" happy-path.

Asserting both pending state and final match context is great; it verifies beforeLoad is wired correctly and its return value is plumbed into the match.


83-93: LGTM: "preload resolved successfully" is covered well.

The single invocation assertion after navigating is the right behavior here.


124-137: LGTM: redirect-on-preload does not side-effect navigation.

Good assertion that real navigation ends at /foo and beforeLoad is invoked twice.

@larrifax
Copy link
Contributor

Would it make sense to also include tests for the following?

  1. the expected calls of a parent's beforeLoad in case of navigation to a nested route (url: parent/child)
  2. calls to the rootRoute's beforeLoad when navigating to e.g. /foo (don't know if root has special handling or not)

Reason I'm asking is that I've filed #4572, where it seems like beforeLoad calls down the route tree has differing internal logic for calculating what parameters they supply to the beforeLoad invocations - which leads me to think that this PR would benefit from having tests checking correct beforeLoad-invocations on more than just the leaf nodes in the "match-tree".

@Sheraff
Copy link
Contributor Author

Sheraff commented Aug 19, 2025

Would it make sense to also include tests for the following?

  1. the expected calls of a parent's beforeLoad in case of navigation to a nested route (url: parent/child)
  2. calls to the rootRoute's beforeLoad when navigating to e.g. /foo (don't know if root has special handling or not)

Reason I'm asking is that I've filed #4572, where it seems like beforeLoad calls down the route tree has differing internal logic for calculating what parameters they supply to the beforeLoad invocations - which leads me to think that this PR would benefit from having tests checking correct beforeLoad-invocations on more than just the leaf nodes in the "match-tree".

Definitely, I think tests inside router-core should be consolidated to validate more behaviors. For now I only wrote tests for "should we call the beforeLoad or not" because that's what I'm working on.

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 (7)
packages/router-core/tests/load.test.ts (7)

17-40: Reduce duplication across setup functions

You have two almost-identical setup() helpers (one per describe). Consider a single factory that accepts optional beforeLoad and/or loader and returns a router. This will DRY the file and make it easier to add parent/root variants for the extra tests reviewers requested.

Example direction (not exhaustive):

+const makeRouter = ({
+  beforeLoad,
+  loader,
+  extendFoo,
+}: { beforeLoad?: BeforeLoad; loader?: Loader; extendFoo?: (foo: BaseRoute<any>) => void } = {}) => {
+  const rootRoute = new BaseRootRoute({})
+  const fooRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/foo', beforeLoad, loader })
+  extendFoo?.(fooRoute)
+  const barRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/bar' })
+  const routeTree = rootRoute.addChildren([fooRoute, barRoute])
+  return new RouterCore({ routeTree, history: createMemoryHistory() })
+}

41-46: Baseline check is fine; optionally assert initial location

You can also assert the initial pathname to guard against future changes in default history initialization.

 await router.load()
 expect(beforeLoad).toHaveBeenCalledTimes(0)
+expect(router.state.location.pathname).toBe('/')

48-69: Potential race on immediate call-count assertion

navigate may schedule work synchronously today, but asserting toHaveBeenCalledTimes(1) immediately after calling it can become flaky if the internals change. A tiny microtask yield before asserting makes this resilient without changing intent.

 const navigation = router.navigate({ to: '/foo' })
-expect(beforeLoad).toHaveBeenCalledTimes(1)
+await Promise.resolve()
+expect(beforeLoad).toHaveBeenCalledTimes(1)

If you want to go further, also assert the call arg reflects a non-preload invocation (e.g., preload is falsy).


71-187: Add explicit location/state assertions after navigate for consistency

Several tests assert only the call count after await router.navigate(...). Adding an explicit location check reduces the chance of false positives if routing side-effects change in the future. Examples:

@@
 await router.navigate({ to: '/foo' })
 
 expect(beforeLoad).toHaveBeenCalledTimes(2)
+expect(router.state.location.pathname).toBe('/foo')

Consider adding the same assert to:

  • exec if resolved preload (success)
  • exec if pending preload (success)
  • exec if rejected preload (notFound)
  • exec if pending preload (notFound)
  • exec if rejected preload (error)
  • exec if pending preload (error)

190-214: Mirror the setup refactor here too

Same duplication as the beforeLoad block. Using a single makeRouter (see earlier comment) will remove drift between suites.


245-361: Strengthen assertions in loader tests and guard against timing flake

  • Where you await router.navigate(...), consider asserting final pathname (missing in several cases).
  • In “skip if pending preload (success|notFound|redirect|error)” add a short microtask yield before the first call-count assertion, mirroring the earlier navigate race comment.

Example snippets:

@@
 await router.navigate({ to: '/foo' })
 
-expect(loader).toHaveBeenCalledTimes(2)
+expect(loader).toHaveBeenCalledTimes(2)
+expect(router.state.location.pathname).toBe('/foo')
@@
 router.preloadRoute({ to: '/foo' })
-await Promise.resolve()
+await Promise.resolve()
 expect(router.state.cachedMatches).toEqual(
   expect.arrayContaining([expect.objectContaining({ id: '/foo' })]),
 )

364-366: Consider fake timers for deterministic sleeps

Real timers (10/100ms) can slow the suite and be flaky under load. Vitest fake timers plus vi.runOnlyPendingTimersAsync() make these tests deterministic.

Example direction:

-import { describe, expect, test, vi } from 'vitest'
+import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest'
+beforeEach(() => vi.useFakeTimers())
+afterEach(() => vi.useRealTimers())
@@
-  await sleep(10)
+  await vi.runOnlyPendingTimersAsync()
📜 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 0b56d04 and 67c7e04.

📒 Files selected for processing (1)
  • packages/router-core/tests/load.test.ts (1 hunks)
⏰ 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/tests/load.test.ts (2)

1-15: Types and imports look good

Solid use of RouteOptions to derive BeforeLoad/Loader types; using NonNullable ensures the mocks match the real signatures.


127-157: Double‑check intent: redirect semantics differ for beforeLoad vs loader

In the pending preload + redirect case, beforeLoad keeps the final pathname at /foo, whereas loader redirects to /bar. If intentional, please codify that in docs/spec to avoid future “fixes” that align them. If not intentional, we should harmonize.

Do you want me to open a follow-up test that clearly documents this divergence and references the agreed behavior?

Comment on lines +16 to +188
expect(router.state.cachedMatches).toEqual(
expect.arrayContaining([expect.objectContaining({ id: '/foo' })]),
)
await router.navigate({ to: '/foo' })

expect(beforeLoad).toHaveBeenCalledTimes(2)
})

test('exec if rejected preload (notFound)', async () => {
const beforeLoad = vi.fn<BeforeLoad>(async ({ preload }) => {
if (preload) throw notFound()
await Promise.resolve()
})
const router = setup({
beforeLoad,
})
await router.preloadRoute({ to: '/foo' })
await sleep(10)
await router.navigate({ to: '/foo' })

expect(beforeLoad).toHaveBeenCalledTimes(2)
})

test('exec if pending preload (notFound)', async () => {
const beforeLoad = vi.fn<BeforeLoad>(async ({ preload }) => {
await sleep(100)
if (preload) throw notFound()
})
const router = setup({
beforeLoad,
})
router.preloadRoute({ to: '/foo' })
await Promise.resolve()
await router.navigate({ to: '/foo' })

expect(beforeLoad).toHaveBeenCalledTimes(2)
})

test('exec if rejected preload (redirect)', async () => {
const beforeLoad = vi.fn<BeforeLoad>(async ({ preload }) => {
if (preload) throw redirect({ to: '/bar' })
await Promise.resolve()
})
const router = setup({
beforeLoad,
})
await router.preloadRoute({ to: '/foo' })
await sleep(10)
await router.navigate({ to: '/foo' })

expect(router.state.location.pathname).toBe('/foo')
expect(beforeLoad).toHaveBeenCalledTimes(2)
})

test('exec if pending preload (redirect)', async () => {
const beforeLoad = vi.fn<BeforeLoad>(async ({ preload }) => {
await sleep(100)
if (preload) throw redirect({ to: '/bar' })
})
const router = setup({
beforeLoad,
})
router.preloadRoute({ to: '/foo' })
await Promise.resolve()
await router.navigate({ to: '/foo' })

expect(router.state.location.pathname).toBe('/foo')
expect(beforeLoad).toHaveBeenCalledTimes(2)
})

test('exec if rejected preload (error)', async () => {
const beforeLoad = vi.fn<BeforeLoad>(async ({ preload }) => {
if (preload) throw new Error('error')
await Promise.resolve()
})
const router = setup({
beforeLoad,
})
await router.preloadRoute({ to: '/foo' })
await sleep(10)
await router.navigate({ to: '/foo' })

expect(beforeLoad).toHaveBeenCalledTimes(2)
})

test('exec if pending preload (error)', async () => {
const beforeLoad = vi.fn<BeforeLoad>(async ({ preload }) => {
await sleep(100)
if (preload) throw new Error('error')
})
const router = setup({
beforeLoad,
})
router.preloadRoute({ to: '/foo' })
await Promise.resolve()
await router.navigate({ to: '/foo' })

expect(beforeLoad).toHaveBeenCalledTimes(2)
})
})
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Add coverage for parent and root beforeLoad as requested in review

Per reviewer feedback (issue #4572 context), add tests proving parent and root beforeLoad are invoked appropriately when navigating to nested and top‑level routes. Below is a minimal additive patch you can drop after the existing “beforeLoad skip or exec” suite.

@@
 describe('beforeLoad skip or exec', () => {
   // ...existing tests...
 })
 
+describe('beforeLoad parent/root propagation', () => {
+  test('parent beforeLoad is called when navigating to a nested child', async () => {
+    const root = new BaseRootRoute({})
+    const parentBeforeLoad = vi.fn<BeforeLoad>(() => Promise.resolve({ parent: true }))
+    const childBeforeLoad = vi.fn<BeforeLoad>(() => Promise.resolve({ child: true }))
+    const parent = new BaseRoute({ getParentRoute: () => root, path: '/foo', beforeLoad: parentBeforeLoad })
+    const child = new BaseRoute({ getParentRoute: () => parent, path: 'child', beforeLoad: childBeforeLoad })
+    parent.addChildren([child])
+    const tree = root.addChildren([parent])
+    const router = new RouterCore({ routeTree: tree, history: createMemoryHistory() })
+
+    await router.navigate({ to: '/foo/child' })
+
+    expect(parentBeforeLoad).toHaveBeenCalledTimes(1)
+    expect(childBeforeLoad).toHaveBeenCalledTimes(1)
+    expect(router.state.location.pathname).toBe('/foo/child')
+    // Optional: parent should run before child
+    expect(parentBeforeLoad.mock.invocationCallOrder[0]).toBeLessThan(childBeforeLoad.mock.invocationCallOrder[0])
+  })
+
+  test('root beforeLoad is called when navigating to a top-level route', async () => {
+    const rootBeforeLoad = vi.fn<BeforeLoad>(() => Promise.resolve({ fromRoot: true }))
+    const root = new BaseRootRoute({ beforeLoad: rootBeforeLoad as any })
+    const foo = new BaseRoute({ getParentRoute: () => root, path: '/foo' })
+    const tree = root.addChildren([foo])
+    const router = new RouterCore({ routeTree: tree, history: createMemoryHistory() })
+
+    await router.navigate({ to: '/foo' })
+
+    expect(rootBeforeLoad).toHaveBeenCalledTimes(1)
+    expect(router.state.location.pathname).toBe('/foo')
+  })
+})

If BaseRootRoute doesn’t accept beforeLoad in your version, we can instead attach beforeLoad to a top-level “/” route that parents “/foo”.

📝 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
describe('beforeLoad skip or exec', () => {
const setup = ({ beforeLoad }: { beforeLoad?: BeforeLoad }) => {
const rootRoute = new BaseRootRoute({})
const fooRoute = new BaseRoute({
getParentRoute: () => rootRoute,
path: '/foo',
beforeLoad,
})
const barRoute = new BaseRoute({
getParentRoute: () => rootRoute,
path: '/bar',
})
const routeTree = rootRoute.addChildren([fooRoute, barRoute])
const router = new RouterCore({
routeTree,
history: createMemoryHistory(),
})
return router
}
test('baseline', async () => {
const beforeLoad = vi.fn()
const router = setup({ beforeLoad })
await router.load()
expect(beforeLoad).toHaveBeenCalledTimes(0)
})
test('exec on regular nav', async () => {
const beforeLoad = vi.fn(() => Promise.resolve({ hello: 'world' }))
const router = setup({ beforeLoad })
const navigation = router.navigate({ to: '/foo' })
expect(beforeLoad).toHaveBeenCalledTimes(1)
expect(router.state.pendingMatches).toEqual(
expect.arrayContaining([expect.objectContaining({ id: '/foo' })]),
)
await navigation
expect(router.state.location.pathname).toBe('/foo')
expect(router.state.matches).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: '/foo',
context: {
hello: 'world',
},
}),
]),
)
expect(beforeLoad).toHaveBeenCalledTimes(1)
})
test('exec if resolved preload (success)', async () => {
const beforeLoad = vi.fn()
const router = setup({ beforeLoad })
await router.preloadRoute({ to: '/foo' })
expect(router.state.cachedMatches).toEqual(
expect.arrayContaining([expect.objectContaining({ id: '/foo' })]),
)
await sleep(10)
await router.navigate({ to: '/foo' })
expect(beforeLoad).toHaveBeenCalledTimes(2)
})
test('exec if pending preload (success)', async () => {
const beforeLoad = vi.fn(() => sleep(100))
const router = setup({ beforeLoad })
router.preloadRoute({ to: '/foo' })
await Promise.resolve()
expect(router.state.cachedMatches).toEqual(
expect.arrayContaining([expect.objectContaining({ id: '/foo' })]),
)
await router.navigate({ to: '/foo' })
expect(beforeLoad).toHaveBeenCalledTimes(2)
})
test('exec if rejected preload (notFound)', async () => {
const beforeLoad = vi.fn<BeforeLoad>(async ({ preload }) => {
if (preload) throw notFound()
await Promise.resolve()
})
const router = setup({
beforeLoad,
})
await router.preloadRoute({ to: '/foo' })
await sleep(10)
await router.navigate({ to: '/foo' })
expect(beforeLoad).toHaveBeenCalledTimes(2)
})
test('exec if pending preload (notFound)', async () => {
const beforeLoad = vi.fn<BeforeLoad>(async ({ preload }) => {
await sleep(100)
if (preload) throw notFound()
})
const router = setup({
beforeLoad,
})
router.preloadRoute({ to: '/foo' })
await Promise.resolve()
await router.navigate({ to: '/foo' })
expect(beforeLoad).toHaveBeenCalledTimes(2)
})
test('exec if rejected preload (redirect)', async () => {
const beforeLoad = vi.fn<BeforeLoad>(async ({ preload }) => {
if (preload) throw redirect({ to: '/bar' })
await Promise.resolve()
})
const router = setup({
beforeLoad,
})
await router.preloadRoute({ to: '/foo' })
await sleep(10)
await router.navigate({ to: '/foo' })
expect(router.state.location.pathname).toBe('/foo')
expect(beforeLoad).toHaveBeenCalledTimes(2)
})
test('exec if pending preload (redirect)', async () => {
const beforeLoad = vi.fn<BeforeLoad>(async ({ preload }) => {
await sleep(100)
if (preload) throw redirect({ to: '/bar' })
})
const router = setup({
beforeLoad,
})
router.preloadRoute({ to: '/foo' })
await Promise.resolve()
await router.navigate({ to: '/foo' })
expect(router.state.location.pathname).toBe('/foo')
expect(beforeLoad).toHaveBeenCalledTimes(2)
})
test('exec if rejected preload (error)', async () => {
const beforeLoad = vi.fn<BeforeLoad>(async ({ preload }) => {
if (preload) throw new Error('error')
await Promise.resolve()
})
const router = setup({
beforeLoad,
})
await router.preloadRoute({ to: '/foo' })
await sleep(10)
await router.navigate({ to: '/foo' })
expect(beforeLoad).toHaveBeenCalledTimes(2)
})
test('exec if pending preload (error)', async () => {
const beforeLoad = vi.fn<BeforeLoad>(async ({ preload }) => {
await sleep(100)
if (preload) throw new Error('error')
})
const router = setup({
beforeLoad,
})
router.preloadRoute({ to: '/foo' })
await Promise.resolve()
await router.navigate({ to: '/foo' })
expect(beforeLoad).toHaveBeenCalledTimes(2)
})
})
// … existing “beforeLoad skip or exec” tests …
})
describe('beforeLoad parent/root propagation', () => {
test('parent beforeLoad is called when navigating to a nested child', async () => {
const root = new BaseRootRoute({})
const parentBeforeLoad = vi.fn<BeforeLoad>(() => Promise.resolve({ parent: true }))
const childBeforeLoad = vi.fn<BeforeLoad>(() => Promise.resolve({ child: true }))
const parent = new BaseRoute({
getParentRoute: () => root,
path: '/foo',
beforeLoad: parentBeforeLoad,
})
const child = new BaseRoute({
getParentRoute: () => parent,
path: 'child',
beforeLoad: childBeforeLoad,
})
parent.addChildren([child])
const tree = root.addChildren([parent])
const router = new RouterCore({
routeTree: tree,
history: createMemoryHistory(),
})
await router.navigate({ to: '/foo/child' })
expect(parentBeforeLoad).toHaveBeenCalledTimes(1)
expect(childBeforeLoad).toHaveBeenCalledTimes(1)
expect(router.state.location.pathname).toBe('/foo/child')
// Ensure parent runs before child
expect(
parentBeforeLoad.mock.invocationCallOrder[0],
).toBeLessThan(childBeforeLoad.mock.invocationCallOrder[0])
})
test('root beforeLoad is called when navigating to a top-level route', async () => {
const rootBeforeLoad = vi.fn<BeforeLoad>(() =>
Promise.resolve({ fromRoot: true }),
)
const root = new BaseRootRoute({
beforeLoad: rootBeforeLoad as any,
})
const foo = new BaseRoute({
getParentRoute: () => root,
path: '/foo',
})
const tree = root.addChildren([foo])
const router = new RouterCore({
routeTree: tree,
history: createMemoryHistory(),
})
await router.navigate({ to: '/foo' })
expect(rootBeforeLoad).toHaveBeenCalledTimes(1)
expect(router.state.location.pathname).toBe('/foo')
})
})
🤖 Prompt for AI Agents
packages/router-core/tests/load.test.ts lines 16-188: add new tests after the
existing "beforeLoad skip or exec" suite that verify parent and root beforeLoad
handlers are invoked when navigating to nested and top-level routes;
specifically, create a parent route (or attach to BaseRootRoute if it accepts
beforeLoad) with a vi.fn() beforeLoad and a child route '/foo', navigate to
'/foo' and assert parent beforeLoad was called once (and child beforeLoad
behavior as appropriate), and also add a test that attaches a beforeLoad to the
root (or a top-level "/" route if BaseRootRoute doesn't accept it) and navigates
to '/' to assert the root beforeLoad is called; ensure tests use the existing
setup pattern (create RouterCore with routeTree and memory history) and account
for preload timing analogous to existing tests (use Promise.resolve or sleep
where needed).

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.

2 participants