Skip to content

fix: prevent infinite redirect loop in beforeEnter guard#1919

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 guard#1919
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

  • New Features
    • Added a limit for chained redirects in router navigation, with a default maximum depth to prevent endless loops.
  • Bug Fixes
    • Infinite redirect loops during navigation now stop with an error instead of continuing indefinitely.
    • Redirect tracking is reset after successful navigation, so valid redirect chains complete normally.
  • Tests
    • Added coverage for redirect-loop handling in both navigation modes and for successful redirect chains.

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

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Router gains a maxRedirectDepth option (default 10) tracked via new internal state. Both _navigateTo and replace now increment a redirect counter when beforeEnter returns a redirect string, emitting an error event and aborting if the limit is exceeded; the counter resets otherwise. Tests validate this behavior.

Changes

Redirect Loop Prevention

Layer / File(s) Summary
Redirect depth option and state
packages/router/src/router.ts
RouterOptions gains maxRedirectDepth?: number (default 10); Router constructor initializes _maxRedirectDepth and _redirectDepth fields.
Navigation guard redirect handling
packages/router/src/router.ts
_navigateTo and replace update beforeEnter handling to reset depth on false, increment/check depth on string redirects, emit error and abort on overflow, and reset depth on non-redirect execution.
Redirect depth tests
packages/router/src/router.test.ts
New tests validate infinite redirect loop detection during push and replace, and depth reset after a successful non-looping redirect chain.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant Router
  participant BeforeEnterGuard
  Caller->>Router: push/replace(path)
  Router->>BeforeEnterGuard: beforeEnter(path)
  BeforeEnterGuard-->>Router: returns string (redirect)
  Router->>Router: increment _redirectDepth
  alt depth exceeds _maxRedirectDepth
    Router->>Router: emit error event
    Router-->>Caller: abort navigation
  else depth within limit
    Router->>Router: navigate to redirect target
    Router->>Router: reset _redirectDepth on completion
  end
Loading

Possibly related PRs

  • Karanjot786/TermUI#1163: Both PRs modify router.ts to add a maximum-redirect-depth limit that emits an error and aborts on cyclic redirects.
  • Karanjot786/TermUI#1171: Both PRs modify beforeEnter string redirect resolution in the router's navigation lifecycle.
  • Karanjot786/TermUI#839: Both PRs update router.test.ts to validate beforeEnter-driven redirect behavior during push/replace.

Suggested labels: quality:clean, type:bug, level:intermediate, area:router

Suggested reviewers: Karanjot786

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise, correctly scoped to the redirect-loop fix, and matches the main change.
Description check ✅ Passed The description includes the required sections, linked issue, package, change type, checklist, and reviewer notes.
Linked Issues check ✅ Passed The changes add redirect-depth tracking, limit looping redirects, reset depth correctly, and cover the behavior with tests.
Out of Scope Changes check ✅ Passed The PR stays focused on router redirect-loop protection and related tests with no clear unrelated additions.
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.

@github-actions github-actions Bot added the type:bug +10 pts. Bug fix. label Jul 1, 2026

@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: 2

Caution

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

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

183-211: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

_redirectDepth leaks across navigations when a redirect targets a non-existent route.

Because _redirectDepth is now instance state that is only cleared on false, overflow, or a terminal (non-redirect) result, the !match early return at Lines 186-189 does not reset it. If a beforeEnter redirect points to a path with no matching route, navigation aborts with the counter still elevated. A later, unrelated redirect chain then starts from a non-zero depth and can spuriously trip Maximum redirect depth ... Possible infinite redirect loop. The same leak exists in replace (Lines 242-245).

Reset the counter on the no-match path (in both _navigateTo and replace):

         if (!match) {
+            this._redirectDepth = 0;
             this.events.emit('error', new Error(`No route found for path: ${path}`));
             return;
         }
🤖 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 - 211, Reset the router’s
redirect counter when navigation fails to match a route, since `_redirectDepth`
in `Router._navigateTo` is currently left elevated after the `!match` early
return. Update both `_navigateTo` and `replace` so the no-route-found path
clears `_redirectDepth` before emitting the error and returning, keeping later
`beforeEnter` redirect chains from inheriting stale state and tripping the
max-depth guard spuriously.
🧹 Nitpick comments (1)
packages/router/src/router.ts (1)

239-265: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider extracting the shared beforeEnter redirect-depth handling.

Lines 195-211 in _navigateTo and Lines 249-265 here are identical guard/depth logic. A small helper (e.g. _resolveGuard(path, guardResult, redirect) returning 'stop' | 'redirect' | 'continue') would keep the two navigation flows from diverging as this logic evolves. Optional given the current size.

🤖 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 239 - 265, The redirect-depth and
beforeEnter guard handling in replace duplicates the same logic used in
_navigateTo, so extract it into a shared helper on Router to keep both
navigation paths consistent. Create a small method such as _resolveGuard that
takes the path, guardResult, and redirect action and returns a clear outcome
like stop, redirect, or continue, then use it from replace and _navigateTo
instead of maintaining two separate copies of the depth/error logic.
🤖 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.test.ts`:
- Around line 244-289: Add a test in Router coverage for a `beforeEnter`
redirect to a missing route followed by a normal redirect chain, using the real
`Router` and `events.on('error')` like the existing `beforeEnter`/`replace`
tests. The new case should exercise the no-match path in `Router` navigation
(where `_redirectDepth` can leak) by redirecting from one route to a
non-existent target, then performing a successful redirect sequence and
asserting no unexpected error plus the final `currentPath` is correct. Reference
the existing `Router`, `beforeEnter`, `push`, and `replace` test patterns so
it’s easy to place alongside the current redirect-depth tests.

In `@packages/router/src/router.ts`:
- Around line 37-38: Update the router option doc comment for maxRedirectDepth
so it matches the actual behavior in router.ts: it does not throw, it emits an
error event via this.events.emit('error', ...) and returns. Adjust the wording
in the Router interface/option docs to say the redirect chain limit triggers an
error event instead of a thrown error, so consumers using push() or replace()
won’t expect try/catch handling.

---

Outside diff comments:
In `@packages/router/src/router.ts`:
- Around line 183-211: Reset the router’s redirect counter when navigation fails
to match a route, since `_redirectDepth` in `Router._navigateTo` is currently
left elevated after the `!match` early return. Update both `_navigateTo` and
`replace` so the no-route-found path clears `_redirectDepth` before emitting the
error and returning, keeping later `beforeEnter` redirect chains from inheriting
stale state and tripping the max-depth guard spuriously.

---

Nitpick comments:
In `@packages/router/src/router.ts`:
- Around line 239-265: The redirect-depth and beforeEnter guard handling in
replace duplicates the same logic used in _navigateTo, so extract it into a
shared helper on Router to keep both navigation paths consistent. Create a small
method such as _resolveGuard that takes the path, guardResult, and redirect
action and returns a clear outcome like stop, redirect, or continue, then use it
from replace and _navigateTo instead of maintaining two separate copies of the
depth/error logic.
🪄 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: d57cf997-0040-450a-98ee-e69f6c903fe3

📥 Commits

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

📒 Files selected for processing (2)
  • packages/router/src/router.test.ts
  • packages/router/src/router.ts

Comment on lines +244 to +289

it('beforeEnter infinite redirect loop is detected and prevented', () => {
const r = new Router({ maxRedirectDepth: 3 });
const errorFn = vi.fn();
r.events.on('error', errorFn);

// Create infinite redirect loop: /a -> /b -> /a -> /b -> ...
r.addRoute('/a', () => 'A', undefined, { beforeEnter: () => '/b' });
r.addRoute('/b', () => 'B', undefined, { beforeEnter: () => '/a' });

r.push('/a');

expect(errorFn).toHaveBeenCalled();
expect(errorFn.mock.calls[0][0].message).toContain('Maximum redirect depth');
});

it('replace with infinite redirect loop is detected and prevented', () => {
const r = new Router({ maxRedirectDepth: 3 });
const errorFn = vi.fn();
r.events.on('error', errorFn);

// Create infinite redirect loop: /a -> /b -> /a -> /b -> ...
r.addRoute('/a', () => 'A', undefined, { beforeEnter: () => '/b' });
r.addRoute('/b', () => 'B', undefined, { beforeEnter: () => '/a' });

r.replace('/a');

expect(errorFn).toHaveBeenCalled();
expect(errorFn.mock.calls[0][0].message).toContain('Maximum redirect depth');
});

it('beforeEnter redirect depth is reset on successful navigation', () => {
const r = new Router({ maxRedirectDepth: 3 });
const errorFn = vi.fn();
r.events.on('error', errorFn);

// Create redirect chain that doesn't loop: /a -> /b -> /c
r.addRoute('/a', () => 'A', undefined, { beforeEnter: () => '/b' });
r.addRoute('/b', () => 'B', undefined, { beforeEnter: () => '/c' });
r.addRoute('/c', () => 'C');

r.push('/a');

expect(errorFn).not.toHaveBeenCalled();
expect(r.currentPath).toBe('/c');
});

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 | 🟡 Minor | ⚡ Quick win

Tests assert observable behavior (error emission, message content, resulting currentPath) and use the real Router — good.

One coverage gap worth adding: a case where a beforeEnter redirect targets a non-existent route, then a subsequent legitimate redirect chain navigates successfully. This would guard against the _redirectDepth leak flagged in router.ts (no-match early return not resetting the counter).

🤖 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.test.ts` around lines 244 - 289, Add a test in
Router coverage for a `beforeEnter` redirect to a missing route followed by a
normal redirect chain, using the real `Router` and `events.on('error')` like the
existing `beforeEnter`/`replace` tests. The new case should exercise the
no-match path in `Router` navigation (where `_redirectDepth` can leak) by
redirecting from one route to a non-existent target, then performing a
successful redirect sequence and asserting no unexpected error plus the final
`currentPath` is correct. Reference the existing `Router`, `beforeEnter`,
`push`, and `replace` test patterns so it’s easy to place alongside the current
redirect-depth tests.

Comment on lines +37 to +38
/** Maximum redirect chain depth before throwing error (default: 10) */
maxRedirectDepth?: number;

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

Doc says "throwing error" but the router emits an error event instead.

The implementation calls this.events.emit('error', ...) and returns; it never throws. Consumers reading this doc may wrap push()/replace() in try/catch and silently miss the loop condition (which, with no error listener registered, is swallowed by EventEmitter.emit). Align the wording with the actual behavior.

📝 Suggested doc tweak
-    /** Maximum redirect chain depth before throwing error (default: 10) */
+    /** Maximum redirect chain depth before an `error` event is emitted and navigation aborts (default: 10) */
📝 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
/** Maximum redirect chain depth before throwing error (default: 10) */
maxRedirectDepth?: number;
/** Maximum redirect chain depth before an `error` event is emitted and navigation aborts (default: 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 37 - 38, Update the router option
doc comment for maxRedirectDepth so it matches the actual behavior in router.ts:
it does not throw, it emits an error event via this.events.emit('error', ...)
and returns. Adjust the wording in the Router interface/option docs to say the
redirect chain limit triggers an error event instead of a thrown error, so
consumers using push() or replace() won’t expect try/catch handling.

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