Skip to content

fix: prevent infinite redirect loop in beforeEnter guards#1924

Closed
madhuri-perumalla wants to merge 2 commits into
Karanjot786:mainfrom
madhuri-perumalla:fix/router--redirect--loop
Closed

fix: prevent infinite redirect loop in beforeEnter guards#1924
madhuri-perumalla wants to merge 2 commits into
Karanjot786:mainfrom
madhuri-perumalla:fix/router--redirect--loop

Conversation

@madhuri-perumalla

@madhuri-perumalla madhuri-perumalla commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Description

This PR fixes an issue where beforeEnter route guards could redirect between routes indefinitely, resulting in infinite navigation loops that could freeze the application or cause a stack overflow.

The router now tracks redirect depth during a navigation and throws an error when the configured maximum redirect limit is exceeded, preventing runaway redirect chains while preserving valid redirect behavior.

Related Issue

Closes #1918

Which package(s)?

  • @termuijs/core

Type of Change

  • 🐛 Bug fix (type:bug)
  • 🧪 Tests (type:testing)

Checklist

  • ⭐ You starred the repo.
  • Tests pass locally: bun vitest run
  • Build passes: bun run build
  • Typecheck passes: bun run typecheck
  • You read CONTRIBUTING.md.
  • Your PR title follows type: short description.
  • Widget state mutators call markDirty() (if applicable).
  • No new any types without an inline comment explaining why.
  • No unrelated refactors bundled into this PR.

GSSoC 2026 Participation

  • Yes , I am a GSSoC 2026 contributor.

Notes for the Reviewer

Changes

  • Added a configurable maxRedirectDepth option to RouterOptions (default: 10).
  • Tracked redirect depth during navigation.
  • Prevented infinite redirect loops by throwing an error when the maximum redirect depth is exceeded.
  • Reset redirect depth after successful navigation or when navigation is cancelled by a guard.
  • Applied the same protection to both push() and replace() navigation flows.

Tests

  • Added a test for infinite redirect loop detection using push().
  • Added a test for infinite redirect loop detection using replace().
  • Added a test verifying that redirect depth is reset after a successful navigation chain.

This change is backward compatible for existing applications while preventing application hangs caused by misconfigured route guards.

Summary by CodeRabbit

  • Bug Fixes
    • Redirects now resolve more consistently across static, parameterized, and chained routes.
    • Redirect loops show a clearer “Too many redirects” error.
    • Successful redirects no longer leave navigation in a bad state; later navigations continue to work normally.
    • Navigating through a redirect now updates the current path correctly.

@github-actions github-actions Bot added type:testing +10 pts. Tests. type:bug +10 pts. Bug fix. labels Jul 1, 2026
@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@madhuri-perumalla, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 2 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 704a3787-c2f6-4f37-a043-07dd3db7f0be

📥 Commits

Reviewing files that changed from the base of the PR and between 05f765b and 80183c1.

📒 Files selected for processing (1)
  • packages/router/src/hooks.ts
📝 Walkthrough

Walkthrough

The router's redirect mechanism is refactored from a dedicated redirect route option to beforeEnter returning a string path, with a new redirect-depth guard preventing infinite loops. Navigation control flow (push/replace/back/forward) is restructured via a new _navigateTo method, and query/notFound/autoUnmount APIs are removed. Tests updated accordingly.

Changes

Router redirect and navigation refactor

Layer / File(s) Summary
Type imports and Router state
packages/router/src/router.ts
Removes QueryParams/RedirectTarget imports; drops _notFound/autoUnmount state and adds _redirectDepth/_maxRedirectDepth tracking.
addRoute/addRoutes normalization
packages/router/src/router.ts
Removes redirect option support from addRoute overloads and meta normalization; addRoutes forwards only lazy, beforeEnter, afterEnter.
Navigation control flow rewrite
packages/router/src/router.ts
Introduces _navigateTo; beforeEnter string returns trigger redirects with bounded depth, false blocks navigation; restructures history/forward stacks for push/replace/back/forward; simplifies navigate/back event payloads; always calls unmountAll(); removes query getter, notFound option, direction field.
Redirect test suite updates
packages/router/src/redirect.test.ts
Converts redirect tests (static, param-based, replace, chained) to use beforeEnter; updates cyclic redirect assertions to /Too many redirects/ and verifies redirect-depth reset after success.
Router test suite updates
packages/router/src/router.test.ts
Adds assertion that currentPath updates to redirected path after beforeEnter redirect; removes obsolete back() history-corruption test.

Estimated code review effort: 4 (Complex) | ~50 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Test
  participant Router
  participant BeforeEnter

  Test->>Router: push(path)
  Router->>Router: _navigateTo(path)
  Router->>BeforeEnter: beforeEnter(to)
  BeforeEnter-->>Router: returns string, false, or undefined

  alt string redirect
    Router->>Router: increment _redirectDepth
    alt depth exceeds _maxRedirectDepth
      Router->>Test: emit error("Too many redirects")
    else
      Router->>Router: _navigateTo(redirectPath)
    end
  else false
    Router->>Test: navigation blocked
  else no redirect
    Router->>Router: reset _redirectDepth
    Router->>Test: emit navigate {match, screen}
  end
Loading

Possibly related PRs

  • Karanjot786/TermUI#1163: Both PRs modify router redirect handling and redirect-related tests; #1163 added route.redirect cycle-depth handling that this PR replaces with beforeEnter-based redirection.
  • Karanjot786/TermUI#839: Both PRs are tied to beforeEnter redirect semantics, including the /admin/login redirect test scenario.
  • Karanjot786/TermUI#1040: Both PRs modify push/replace signatures and query handling on the router, with this PR removing query while #1040 reintroduces query exposure.

Suggested labels: level:intermediate, area:router

Suggested reviewers: Karanjot786

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The router refactor removes unrelated APIs and behaviors like query support, notFound, and event fields beyond the linked issue. Limit the PR to redirect-loop protection and tests, or split unrelated router API removals into separate changes.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: preventing infinite redirect loops in beforeEnter guards.
Description check ✅ Passed The PR description includes all required sections and relevant details from the template.
Linked Issues check ✅ Passed The changes address #1918 by tracking redirect depth and blocking infinite beforeEnter redirect loops with tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/router/src/redirect.test.ts (1)

1-119: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Wire maxRedirectDepth through RouterOptions
RouterOptions still hardcodes the redirect limit at 10. Add maxRedirectDepth to the options and read it in the constructor so new Router({ maxRedirectDepth: N }) actually changes redirect handling.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/router/src/redirect.test.ts` around lines 1 - 119, RouterOptions
currently ignores a configurable redirect limit and still uses a fixed depth of
10. Update the Router constructor and RouterOptions types so maxRedirectDepth is
accepted from new Router({ maxRedirectDepth: N }) and used by the redirect
handling logic instead of the hardcoded default; check the Router class and its
redirect depth tracking/loop detection path so the new option flows through
end-to-end.
packages/router/src/router.ts (1)

136-136: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Defer pending initial navigation until addRoutes() finishes.

Each addRoute() calls _applyInitialPathIfPending(). In a batch, an initial route whose guard redirects to a route registered later in the same addRoutes() call will fail before the target exists and clear the pending path. Suspend initial-path application during the loop, then apply once after all routes are registered.

Also applies to: 152-158, 226-233

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/router/src/router.ts` at line 136, The pending initial navigation is
being applied too early inside the addRoute()/addRoutes() flow, so a guard
redirect can clear the pending path before later routes in the same batch are
registered. Update Router.addRoutes() to suspend _applyInitialPathIfPending()
while iterating through the routes, and only invoke it once after all addRoute()
calls complete; keep the change localized around the addRoute(), addRoutes(),
and _applyInitialPathIfPending() logic.
🧹 Nitpick comments (1)
packages/router/src/redirect.test.ts (1)

99-119: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Reset test doesn't distinguish reset-vs-no-reset behavior.

With only 2 redirects per push('/a') call and maxRedirectDepth of 10, cumulative depth across both pushes (4 total) stays well under the threshold even if the reset were missing entirely. This test would still pass with a broken (non-resetting) implementation. Consider increasing the redirect chain length or repeat count so the total without reset would exceed maxRedirectDepth, e.g. call push('/a') at least 6 times (6×2=12 > 10) and assert no error across all calls.

🧪 Suggested strengthening
         router.push('/a');
         expect(router.currentPath).toBe('/c');
         expect(errorHandler).not.toHaveBeenCalled();

-        // Navigate again should reset depth
-        router.push('/a');
-        expect(router.currentPath).toBe('/c');
-        expect(errorHandler).not.toHaveBeenCalled();
+        // Navigate again multiple times; without a reset, cumulative depth
+        // would exceed maxRedirectDepth (10) and trigger an error.
+        for (let i = 0; i < 6; i++) {
+            router.push('/a');
+            expect(router.currentPath).toBe('/c');
+        }
+        expect(errorHandler).not.toHaveBeenCalled();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/router/src/redirect.test.ts` around lines 99 - 119, The redirect
reset test in redirect.test.ts does not actually prove that redirect depth is
reset between navigations because two redirects per Router.push('/a') stay below
maxRedirectDepth even if the reset is broken. Strengthen the test around
Router.push and the redirect chain (/a -> /b -> /c) by repeating the navigation
enough times that cumulative depth would exceed maxRedirectDepth without a
reset, then assert no errorHandler calls across all pushes and currentPath
remains '/c'.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/router/src/router.ts`:
- Around line 100-118: The options normalization in router should remove
unqualified `any` usage and unexplained assertions. Update the `finalOptions`
typing in `router.ts` to use a shared typed alias for `lazy`, `beforeEnter`, and
`afterEnter` instead of `Promise<any>`, and replace the `meta as any` /
delete-casts with a typed approach or add explicit inline comments where an
assertion is unavoidable. Keep the fix localized to the normalization logic
around `finalOptions`, `childrenOrOptions`, and the `strippedMeta` handling.
- Line 7: `addRoute()` and `addRoutes()` in `router.ts` dropped support for the
existing `redirect` route shape even though `Route` still includes `redirect?:
RedirectTarget`. Restore backward compatibility by accepting `redirect` in the
route inputs, normalizing it into the equivalent `beforeEnter` redirect
behavior, and ensuring both `addRoute` and `addRoutes` continue to forward
existing call shapes without breaking callers. Use the `Route`, `RouteMatch`,
and route registration logic in `router.ts` to keep the API compatible while
preserving current behavior.
- Around line 183-185: The no-match branch in Router navigation leaves
_redirectDepth uncleared, so a failed redirected target can poison later
navigations and trigger “Too many redirects.” Update the no-match handling in
both _navigateTo() and replace() to reset _redirectDepth before returning, using
the existing Router class logic around the match check and redirect flow.
- Around line 291-304: The history replay logic in back() and forward() bypasses
route guards by setting _currentMatch and emitting events directly, so guarded
or redirecting routes can be entered from history without the same checks used
by push() and replace(). Update the route replay flow in router.ts to run the
same beforeEnter guard path before committing the match, and only update
_currentMatch, unmountAll(), and emit the back/forward event after the guard
allows the route; use the existing routing helpers in Router to keep behavior
consistent.
- Around line 188-194: In Router.push, avoid mutating navigation state before
beforeEnter completes: _forwardStack is being cleared too early, so cancelled or
redirected navigations still lose forward history. Move the _forwardStack reset
into the successful commit path in push() right before _history.push(path), and
keep the guard handling around match.route.beforeEnter and the redirect/error
path unchanged so only committed navigations clear forward history.
- Around line 46-51: The redirect cap in Router is currently hard-coded, so
callers cannot configure it through RouterOptions. Update the Router constructor
to assign _maxRedirectDepth from options.maxRedirectDepth ?? 10, and add
maxRedirectDepth to the RouterOptions type/interface if it is missing so the
setting is exposed consistently. Use Router and RouterOptions to locate the
wiring point.

---

Outside diff comments:
In `@packages/router/src/redirect.test.ts`:
- Around line 1-119: RouterOptions currently ignores a configurable redirect
limit and still uses a fixed depth of 10. Update the Router constructor and
RouterOptions types so maxRedirectDepth is accepted from new Router({
maxRedirectDepth: N }) and used by the redirect handling logic instead of the
hardcoded default; check the Router class and its redirect depth tracking/loop
detection path so the new option flows through end-to-end.

In `@packages/router/src/router.ts`:
- Line 136: The pending initial navigation is being applied too early inside the
addRoute()/addRoutes() flow, so a guard redirect can clear the pending path
before later routes in the same batch are registered. Update Router.addRoutes()
to suspend _applyInitialPathIfPending() while iterating through the routes, and
only invoke it once after all addRoute() calls complete; keep the change
localized around the addRoute(), addRoutes(), and _applyInitialPathIfPending()
logic.

---

Nitpick comments:
In `@packages/router/src/redirect.test.ts`:
- Around line 99-119: The redirect reset test in redirect.test.ts does not
actually prove that redirect depth is reset between navigations because two
redirects per Router.push('/a') stay below maxRedirectDepth even if the reset is
broken. Strengthen the test around Router.push and the redirect chain (/a -> /b
-> /c) by repeating the navigation enough times that cumulative depth would
exceed maxRedirectDepth without a reset, then assert no errorHandler calls
across all pushes and currentPath remains '/c'.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 062cc92d-e604-407d-b48e-6eff399c1a35

📥 Commits

Reviewing files that changed from the base of the PR and between 85e8d36 and 05f765b.

📒 Files selected for processing (3)
  • packages/router/src/redirect.test.ts
  • packages/router/src/router.test.ts
  • packages/router/src/router.ts
💤 Files with no reviewable changes (1)
  • packages/router/src/router.test.ts

import { EventEmitter } from '@termuijs/core';
import { createElement, ErrorBoundary, unmountAll, type VNode } from '@termuijs/jsx';
import { type Route, type RouteMatch, type RouteParams, type RouteMeta, type QueryParams, type RedirectTarget, matchRoute, compilePattern } from './route.js';
import { type Route, type RouteMatch, type RouteParams, type RouteMeta, matchRoute, compilePattern } from './route.js';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep redirect route registrations backward compatible.

addRoute() / addRoutes() no longer accept or forward redirect, while Route still exposes redirect?: RedirectTarget. Existing call shapes should keep working by accepting redirect and normalizing it to a beforeEnter redirect. As per coding guidelines, packages/router/**/router.ts: “Keep addRoute and addRoutes backward compatible; existing call shapes must keep working”.

Also applies to: 63-80, 93-104, 141-157

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/router/src/router.ts` at line 7, `addRoute()` and `addRoutes()` in
`router.ts` dropped support for the existing `redirect` route shape even though
`Route` still includes `redirect?: RedirectTarget`. Restore backward
compatibility by accepting `redirect` in the route inputs, normalizing it into
the equivalent `beforeEnter` redirect behavior, and ensuring both `addRoute` and
`addRoutes` continue to forward existing call shapes without breaking callers.
Use the `Route`, `RouteMatch`, and route registration logic in `router.ts` to
keep the API compatible while preserving current behavior.

Source: Coding guidelines

Comment on lines +46 to 51
private _redirectDepth = 0;
private readonly _maxRedirectDepth = 10;
readonly events = new EventEmitter<RouterEvents>();

constructor(options: RouterOptions = {}) {
this._maxHistory = options.maxHistory ?? 100;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Wire maxRedirectDepth through RouterOptions.

The limit is hard-coded to 10, so callers cannot configure the redirect cap as intended. Assign it from options.maxRedirectDepth ?? 10 and add the option to RouterOptions if it is not already there.

Proposed fix
-    private readonly _maxRedirectDepth = 10;
+    private readonly _maxRedirectDepth: number;
@@
     constructor(options: RouterOptions = {}) {
         this._maxHistory = options.maxHistory ?? 100;
+        this._maxRedirectDepth = options.maxRedirectDepth ?? 10;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/router/src/router.ts` around lines 46 - 51, The redirect cap in
Router is currently hard-coded, so callers cannot configure it through
RouterOptions. Update the Router constructor to assign _maxRedirectDepth from
options.maxRedirectDepth ?? 10, and add maxRedirectDepth to the RouterOptions
type/interface if it is missing so the setting is exposed consistently. Use
Router and RouterOptions to locate the wiring point.

Comment on lines 100 to 118
let finalOptions: {
lazy?: () => Promise<any>;
beforeEnter?: (to: string) => boolean | string;
afterEnter?: (to: string) => void;
redirect?: RedirectTarget;
} | undefined = undefined;
} | undefined = options;

if (Array.isArray(childrenOrOptions)) {
children = childrenOrOptions;
} else if (childrenOrOptions && typeof childrenOrOptions === 'object') {
finalOptions = childrenOrOptions as any;
}

if (typeof options === 'string' || typeof options === 'function') {
finalOptions = { ...finalOptions, redirect: options };
} else if (options && typeof options === 'object') {
finalOptions = options;
finalOptions = childrenOrOptions;
}

let finalMeta = meta ?? {};
if (options === undefined && meta && typeof meta === 'object' && ('lazy' in meta || 'beforeEnter' in meta || 'afterEnter' in meta || 'redirect' in meta)) {
if (options === undefined && meta && typeof meta === 'object' && ('lazy' in meta || 'beforeEnter' in meta || 'afterEnter' in meta)) {
finalOptions = meta as any;
const strippedMeta = { ...meta };
delete (strippedMeta as any).lazy;
delete (strippedMeta as any).beforeEnter;
delete (strippedMeta as any).afterEnter;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Avoid any and unexplained assertions in the changed options normalization.

The changed normalization path still uses Promise<any> / as any without inline justification. Prefer a shared typed options alias such as Pick<Route, 'lazy' | 'beforeEnter' | 'afterEnter'>, or add explicit inline comments where assertions are unavoidable. As per coding guidelines, **/*.{ts,tsx}: “No any without an inline comment explaining why. No type assertions without an inline comment explaining why.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/router/src/router.ts` around lines 100 - 118, The options
normalization in router should remove unqualified `any` usage and unexplained
assertions. Update the `finalOptions` typing in `router.ts` to use a shared
typed alias for `lazy`, `beforeEnter`, and `afterEnter` instead of
`Promise<any>`, and replace the `meta as any` / delete-casts with a typed
approach or add explicit inline comments where an assertion is unavoidable. Keep
the fix localized to the normalization logic around `finalOptions`,
`childrenOrOptions`, and the `strippedMeta` handling.

Source: Coding guidelines

Comment on lines 183 to 185
if (!match) {
if (this._notFound) {
if (options.clearForwardStack) {
this._forwardStack = [];
}

const { modifyHistory = 'push', direction = 'push' } = options;

if (modifyHistory === 'push') {
this._history.push(resolvedPath);

if (this._history.length > this._maxHistory) {
this._history = this._history.slice(-this._maxHistory);
}
} else if (modifyHistory === 'replace') {
if (this._history.length > 0) {
this._history[this._history.length - 1] = resolvedPath;
} else {
this._history.push(resolvedPath);
}
}

const notFoundMatch = this._createNotFoundMatch(resolvedPath);
this._currentMatch = notFoundMatch;
if (this.autoUnmount) unmountAll();
const screen = this._wrapScreen(notFoundMatch);
const emitEvent = direction === 'back' ? 'back' : 'navigate';
this.events.emit(emitEvent, { match: notFoundMatch, screen, direction });
return;
}

this.events.emit('error', new Error(`No route found for path: ${resolvedPath}`));
this.events.emit('error', new Error(`No route found for path: ${path}`));
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reset redirect depth when a redirected target is missing.

If a guard redirects to an unmatched route, _redirectDepth remains non-zero because the no-match branches return without clearing it. The next unrelated redirect chain can then fail early with “Too many redirects”.

Proposed fix
         if (!match) {
+            this._redirectDepth = 0;
             this.events.emit('error', new Error(`No route found for path: ${path}`));
             return;
         }

Apply the same reset in both _navigateTo() and replace().

Also applies to: 239-241

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/router/src/router.ts` around lines 183 - 185, The no-match branch in
Router navigation leaves _redirectDepth uncleared, so a failed redirected target
can poison later navigations and trigger “Too many redirects.” Update the
no-match handling in both _navigateTo() and replace() to reset _redirectDepth
before returning, using the existing Router class logic around the match check
and redirect flow.

Comment on lines +188 to 194
// A new push(path) clears the forward stack
this._forwardStack = [];
const guardResult = match.route.beforeEnter?.(path);

if (guardResult === false) {
this._redirectDepth = 0;
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Clear forward history only after push() commits.

push() clears _forwardStack before beforeEnter runs, so a guard returning false or a redirect error mutates navigation state even though no navigation happened. Move the clear to the successful commit path before _history.push(path).

Proposed fix
-        // A new push(path) clears the forward stack
-        this._forwardStack = [];
         const guardResult = match.route.beforeEnter?.(path);
@@
         this._redirectDepth = 0;
+        // A committed push(path) clears the forward stack
+        this._forwardStack = [];
         this._history.push(path);

Also applies to: 208-210

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/router/src/router.ts` around lines 188 - 194, In Router.push, avoid
mutating navigation state before beforeEnter completes: _forwardStack is being
cleared too early, so cancelled or redirected navigations still lose forward
history. Move the _forwardStack reset into the successful commit path in push()
right before _history.push(path), and keep the guard handling around
match.route.beforeEnter and the redirect/error path unchanged so only committed
navigations clear forward history.

Comment on lines +291 to +304
const prevPath = this._history[this._history.length - 1];
const match = prevPath ? matchRoute(prevPath, this._routes) : null;

this._currentMatch = match;
if (this.autoUnmount) unmountAll();
const screen = this._wrapScreen(match);

this.events.emit('back', { match, screen, direction: 'back' });
if (match) {
unmountAll();

const screen = this._wrapScreen(match);

match.route.afterEnter?.(prevPath);
this.events.emit('back', { match, screen });
} else {
this.events.emit('back', null);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Run beforeEnter when replaying history entries.

back() and forward() set _currentMatch and emit UI events without running guards, so a guarded or redirecting route can be entered through history even when push() / replace() would block or redirect it. Route history replay should share the guard path before committing the match.

Also applies to: 314-325

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/router/src/router.ts` around lines 291 - 304, The history replay
logic in back() and forward() bypasses route guards by setting _currentMatch and
emitting events directly, so guarded or redirecting routes can be entered from
history without the same checks used by push() and replace(). Update the route
replay flow in router.ts to run the same beforeEnter guard path before
committing the match, and only update _currentMatch, unmountAll(), and emit the
back/forward event after the guard allows the route; use the existing routing
helpers in Router to keep behavior consistent.

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

Labels

type:bug +10 pts. Bug fix. type:testing +10 pts. Tests.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[GSSoC] Router beforeEnter guard can cause infinite redirect loop

1 participant