Skip to content

Conversation

omar-hanafy
Copy link
Contributor

@omar-hanafy omar-hanafy commented Dec 22, 2024

Description:
This PR introduces top level onEnter callback in RouteConfiguration to allow developers to intercept and conditionally block navigation based on incoming routes. It adds an example (top_level_on_enter.dart) demonstrating its usage, such as handling referral code from /referral.

What This PR Changes:

  • Adds the onEnter callback (typedef OnEnter) to intercept route navigation before processing.
  • Implements logic for onEnter in GoRouteInformationParser.
  • Updates RouteConfiguration to include the onEnter callback.
  • Adds a new example, top_level_on_enter.dart, to demonstrate the feature.
  • Adds a test case to verify the onEnter callback functionality.

Simple usage example:

final GoRouter router = GoRouter(
  onEnter: (context, state) {
    // Save the referral code (if provided) and block navigation to /referral.
    if (state.uri.path == '/referral') {
      saveReferralCode(context, state.uri.queryParameters['code']);
      return false;
    }

    return true; // Allow navigation for all other routes.
  },
  routes: [ ... ],
);

Impact:
Enables developers to implement route-specific logic, such as support for handling action-based deep links without navigation (160602)

Pre-launch Checklist:

  • I read the [Contributor Guide] and followed the process outlined there for submitting PRs.
  • I read the [Tree Hygiene] page, which explains my responsibilities.
  • I read and followed the [relevant style guides] and ran the auto-formatter. (Unlike the flutter/flutter repo, the flutter/packages repo does use dart format.)
  • I signed the [CLA].
  • The title of the PR starts with the name of the package surrounded by square brackets, e.g., [go_router].
  • I [linked to at least one issue that this PR fixes] in the description above.
  • I updated pubspec.yaml with an appropriate new version according to the [pub versioning philosophy], or this PR is [exempt from version changes].
  • I updated CHANGELOG.md to add a description of the change, [following repository CHANGELOG style], or this PR is [exempt from CHANGELOG changes].
  • I updated/added relevant documentation (doc comments with ///).
  • I added new tests to check the change I am making, or this PR is [test-exempt].
  • All existing and new tests are passing.

Added onEnter callback to enable route interception and demonstrate usage in example.
@omar-hanafy
Copy link
Contributor Author

Hi @chunhtai,

This PR introduces a top-level onEnter callback, addressing critical gap in go_router’s handling of deep links and redirect customizations. It allows developers to intercept routes, block navigation, or implement custom logic.

Given its potential impact, your feedback would be greatly appreciated.

Thank you for your time!

@chunhtai chunhtai self-requested a review January 23, 2025 22:55
@omar-hanafy
Copy link
Contributor Author

Hi @chunhtai, Following up on this PR. I've updated the implementation to:

  1. Enhance OnEnter signature to include current and next state:
typedef OnEnter = bool Function(
  BuildContext context, 
  GoRouterState currentState,
  GoRouterState nextState
);
  1. Deprecate top-level redirect in favor of OnEnter.

Let me know if you'd like any adjustments to this approach.

Provides access to GoRouter within OnEnter callback to support
navigation during early routing stages when InheritedGoRouter
is not yet available in the widget tree.
@omar-hanafy
Copy link
Contributor Author

Hi @chunhtai,

I've expanded the OnEnter callback again to include a GoRouter parameter to handle early-stage navigation. This addition addresses an edge case where navigation is needed before the InheritedGoRouter is available in the widget tree (particularly during initial app launch and deep linking).

The implementation:

  • Adds GoRouter as the fourth parameter to OnEnter
  • Internally adds new _fallbackRouter to be passed to the OnEnter callback when goRouter instance is not yet available through context.

@cedvdb
Copy link
Contributor

cedvdb commented Feb 13, 2025

The callback signature makes sense to me.

The fallback router makes less sense to me. Since it's not the right instance, why would one use it ? Could you add its usage in the example ?

@omar-hanafy
Copy link
Contributor Author

omar-hanafy commented Feb 13, 2025

Hi @cedvdb, The callback signature is indeed straightforward. The fallback router isn’t ideal in that it isn’t coming directly from the widget tree (via GoRouter.of(context)), but it serves as a backup when the Inherited widget isn’t available yet—typically on app startup. In those cases, if you try to look up the router from context, you’d get null because the tree isn’t fully built. Instead, the fallback (usually provided as this when constructing the parser) lets your onEnter callback still have a router reference so you can, for example, call router.go(...) to redirect or modify navigation.

Here’s an example usage:

GoRouter(
  onEnter: (context, currentState, nextState, router) {
    // Even if GoRouter.of(context) is null (because the tree isn’t built yet),
    // the fallback router will be non-null.
    if (nextState.uri.path == '/referral') {
      // Use the router instance (either from context or fallback) to navigate
      router.go('/handle-referral');
      return false; // Prevent default navigation.
    }
    return true;
  },
  // other config...
)

So while it isn’t “the right instance” from context when the tree isn’t ready, it’s our best–available router reference. If someone’s using the router during startup (or in any context where the inherited widget isn’t available), the fallback router provides a safe way to still programmatically navigate.

The idea is that the fallback isn’t a “dummy” or incomplete instance—it’s the same router instance (typically provided as this when constructing the parser). Its methods (like go) work independently of whether the router is currently available in the widget tree. So you can safely call router.go('/handle-referral'); withuot causing errors, even if it wasn’t obtained from the widget context (correct me if I'm wrong).

If you think this fallback might lead to unexpected behavior, one option is to document it clearly and advise that it’s only intended as a temporary solution until the tree is built. Otherwise, it’s our practical solution for early navigation calls.

@cedvdb
Copy link
Contributor

cedvdb commented Feb 13, 2025

typically provided as this

My bad, I misread the code and thought the fallback router was a parameter of the Router constructor, that we had to define ourselves, but that was the RouterInformationParser constructor I was reading.

I will try this out on a decently sized project

);

/// The signature of the onEnter callback.
typedef OnEnter = bool Function(
Copy link
Contributor

Choose a reason for hiding this comment

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

look at this https://docs.google.com/document/d/1L940WGrM1YJ8BkJEpNd67fUqoqC6OsTfch4eH86E0xU/edit?resourcekey=0-9oxsWU1tLpAACTUXyNjpkA&disco=AAABLbcLXnE

the conclusion is that we should return a resolution class where the redirect login will a callback in the returned object.

This will help us to only run the redirection after we get the resolution. I think this will reduce a lot of obscure corner case where the another round of redirection starts before the previous round has finished

Copy link
Contributor

@cedvdb cedvdb Feb 13, 2025

Choose a reason for hiding this comment

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

@chunhtai

In general, stateful behaviors in navigation leads to unpredictable behavior on the web, that is: on the web an user can simply refresh a page and the state will be lost. Stateless redirection and re-redirection is achievable through query parameters (and is standard on the web)

I guess you could make the case that not every one has the web as a target and a stateful redirect is convenient. Still, being restrictive here by requiring statelessness should be considered imo

Maybe an enum may be more understandable than a boolean, else you gotta remember if true means "handled" or "you should handle it" and refer to the doc every time. "stop" and "next" are clearer.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@cedvdb yes while a class-based result might seem more expressive initially, it introduces statefulness that can be problematic on the web. We should lean toward a design that is inherently stateless, leveraging the URL (and query parameters) to persist any transient state needed during redirection.

Copy link
Contributor

@chunhtai chunhtai Feb 13, 2025

Choose a reason for hiding this comment

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

yeah, if it has to return a stateless object, then using enum will be more preferable then boolean. I vaguely remembered I made this decision because there may be resolution other than a yes and no if we were to expand this API.

As return the class, or more specifically doing the redirection in the callback is that i want to avoid corner cases like this

Resolution onEnter(...) {
  router.go('other route');
  await somethingasync();
  return false;
}

when this onEnter is called, the router.go will trigger the onEnter again before the entire redirection pipeline is finished, it is basically a non-tail recursion.

If we do this

Resolution onEnter(...) {
  return Resolution.stop(callBack: () {
      router.go('other route');
     await somethingasync();
  });
}

the route parser can finishes the redirecting pipeline before calling the callback, which will end up being a tail recursion.

My assumption is the non-tail recursion can create obscure bug.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I agree that returning a plain boolean limits us to a yes/no decision, and as you've noted, if we let the side effect (like calling router.go('other route')) happen directly in the callback, it can trigger a reentrant call before the entire redirection pipeline is finished. That non-tail recursion is a recipe for obscure bugs.

By having the callback return a Resolution—an enum that can represent more than just “yes” or “no” - we can include a callback (or similar mechanism) that defers the actual navigation change until after the redirect pipeline is fully processed. In other words, by returning something like:

Resolution.stop(callback: () async {
  await somethingAsync();
  router.go('other route');
});

we ensure that the router.go call isn’t executed until after the parser has finished its work. This effectively makes the redirection pipeline tail-recursive, avoiding the pitfall of re-triggering onEnter mid-flight.

Also I think using an enum here is more future-proof, too .... it gives us room to add more nuanced resolutions if needed. This stateless design fits well with web navigation paradigms where state isn’t preserved across refreshes, and it avoids the pitfalls of having stateful redirection logic.

@cedvdb
Copy link
Contributor

cedvdb commented Feb 14, 2025

Imo onEnter should be made asynchronous instead. One may query a local cache inside for example.

I sent a PR on your fork that doesn't break the synchronous future usage

@omar-hanafy
Copy link
Contributor Author

@cedvdb thanks for the review, I've reviewed the changes in my last commit, and I believe I've resolved the issues effectively:

  • Converting onEnter to be asynchronous to lets us handle operations like cache queries without blocking, also added test case for that.
  • reverted the deprecation on redirectLimit and added support for redirect limit for onEnter, and added test case for the redirectionLimit with onEnter.
  • created new _processOnEnter and re-organized the parseRouteInformationWithDependencies.
  • changed fallbackRouter to router.

@chunhtai Regarding the discussion about using an enum (or even a class-based resolution) for a more expressive API,
I would recommend we stick with the current asynchronous onEnter design. It is inherently stateless, works
better with web navigation paradigms, and avoids the complications of stateful redirection. We can plan for
future enhancements—if we decide that a more nuanced resolution (using an enum) is needed—but for now, this
approach is a good improvement over the old redirect mechanism.

Look at the changes guys and let me know ur thoughts.

- Change onEnter callback signature to FutureOr<bool> for async operations.
- Update _processOnEnter for asynchronous navigation decisions.
- Use last successful match as fallback on blocked navigation, preventing recursion.
- Remove deprecated fallbackRouter parameter.
- Adjust redirect limit and loop detection for stable fallback endpoints.
- Update tests for sync, async, and loop scenarios.
- Improve documentation of design decisions.
@omar-hanafy
Copy link
Contributor Author

hi @chunhtai, whats left for this PR to be merged ?

@chunhtai chunhtai self-requested a review August 19, 2025 16:47
@chunhtai
Copy link
Contributor

I will take a look today, also you can use re-request review when you want me to take another look.

Btw, there is some analyzer error

- Updated copyright notice to include "All rights reserved"
- Fixed formatting to match other files in the repository
- Resolved merge conflicts from upstream changes
@chunhtai
Copy link
Contributor

I left this two comments regarding the unresolved two topics. #8339 (comment)

#8339 (comment)

@omar-hanafy
Copy link
Contributor Author

I left this two comments regarding the unresolved two topics. #8339 (comment)

#8339 (comment)

Oh, yes I did not get any notifications about them, I have replied to them now.

…rect

* New `onEnter` returns `OnEnterResult` (`Allow`/`Block` with optional `then`)
* Legacy top-level `redirect` composed into `onEnter`; parser path simplified
* Normalize URIs; skip `onEnter` on restore; clear vs keep history (`Block()` vs `Block(then)`)
* Refresh example; update/add tests
@omar-hanafy
Copy link
Contributor Author

omar-hanafy commented Aug 20, 2025

Hi @chunhtai, Thanks for the review, here is a summary of what changed in the last commit:

  • API: OnEnterResult is now sealed with a shared then callback:

    • Allow({then}) / Block({then})
    • then runs in a microtask after the decision is committed; errors are reported via FlutterError.reportError and don’t undo navigation.
    • Semantics: Block() = hard stop (history reset). Block(then: …) = chaining (history kept to detect loops).
  • Parser tweaks:

    • Normalize URIs before matching (empty path, trailing slash).
    • On restore/pop, skip onEnter but still run legacy + route-level redirects so branch stacks/state can be corrected (fixes the StatefulShellRoute branch-switch case).
  • Example: updated to the then: pattern (e.g., Block(then: () => router.go(...))), simplified UI feedback, and added a fragment demo via goNamed(..., fragment: ...).

  • Tests: added coverage for restore not calling onEnter, fragments, relative ./ navigation, route-level redirect after allow, Allow(then) error reporting, and hard-stop vs chaining history behavior.

Copy link
Contributor

@chunhtai chunhtai left a comment

Choose a reason for hiding this comment

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

Looks like the toplevel redirect is still there and handled differently. Also do we have plan to add onEnter to all Route? It feels to me that will be easier to write if we all covert at once

/// The result of an [onEnter] callback.
///
/// This sealed class represents the possible outcomes of navigation interception.
sealed class OnEnterResult {
Copy link
Contributor

Choose a reason for hiding this comment

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

Thinking a bit more it feels adding OnEnterResult.allow and OnEnterResult.block may be more confusing than it worth, I suggest we remove them

final Object infoState = routeInformation.state!;

if (state is! RouteInformationState) {
// If we're restoring (pop/back or restoring a branch stack), skip onEnter
Copy link
Contributor

Choose a reason for hiding this comment

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

This seems weird if we are going to replace redirect with onEnter. Should we still attempt to run onenter?

Copy link
Contributor Author

@omar-hanafy omar-hanafy Aug 22, 2025

Choose a reason for hiding this comment

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

skipping onEnter on restore/back is intentional. We still run legacy + route-level redirects to keep URLs correct, but we don’t re-guard restores. The guard for “going back” is GoRoute.onExit, not onEnter.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Also I am afraid we might face issues with iOS back gestures like before with onExit on sub routes.

@omar-hanafy
Copy link
Contributor Author

omar-hanafy commented Aug 22, 2025

Looks like the toplevel redirect is still there and handled differently.

Yep — I kept RoutingConfig.redirect (deprecated) for back-compat, and I run it after the new top-level onEnter and before route-level redirects. The order right now is:

top-level onEnter (new guard)
legacy top-level redirect (deprecated)
route-level GoRoute.redirect

Tests in on_enter_test.dart assert this and that onEnter short-circuits redirect when it blocks.


reading the comments again I understand now u want to compose the legacy top-level redirect into onEnter and let the parser handle a single guard path.

EDIT:
After looking at the code again.
Now Current order is:

  1. onEnter
  2. legacy top-level redirect (deprecated)
  3. route-level GoRoute.redirect

Tests assert onEnter short-circuits when it blocks. Also: restoration currently skips onEnter (intentional) but still runs legacy redirect, which is inconsistent.

Before I change anything, can you confirm these choices?

  1. Unify: Fold legacy top-level redirect into onEnter as a thin adapter (i.e., if onEnter allows, call redirect; if it returns a new location, go() and return Block). Parser then handles only route-level redirects?

  2. Restoration semantics: Should restoration/back bypass all top-level interception (both onEnter and legacy adapter) and only run route-level redirects for state updates? (This avoids “block on back” weirdness.) Preference?

    • A) Bypass both (might be breaking behavior)
    • B) Run onEnter on restore too (not recommended)

Why I still have a valid reason to skip restoration with onEnter.

  • First on enter behavior can completely STOP and do nothing at all using Block
  • This will look weird with methods like pop specially with iOS back button behavior check this #135909.
  • It does not make sense that the name is onEnter and the logic behaves like (onExit).

@omar-hanafy
Copy link
Contributor Author

Also do we have plan to add onEnter to all Route? It feels to me that will be easier to write if we all covert at once

tbh I’d like to land this first, then follow up to add route-level onEnter (which would run before route-level redirect).

My vote: we ship this PR as-is and follow up with route-level onEnter. it won’t break existing apps, and it keeps this change focused (parser + top-level guard + tests).

Simplified the OnEnterResult class documentation and removed factory constructors from the sealed class. Introduced type aliases for navigation callbacks and route information state in parser.dart, and updated method signatures to use these aliases for improved readability and type safety. Also streamlined fallback logic when navigation is blocked.
@omar-hanafy omar-hanafy requested a review from chunhtai August 24, 2025 15:28
@chunhtai
Copy link
Contributor

chunhtai commented Aug 25, 2025

Unify: Fold legacy top-level redirect into onEnter as a thin adapter (i.e., if onEnter allows, call redirect; if it returns a new location, go() and return Block). Parser then handles only route-level redirects?

Yes

Restoration semantics: Should restoration/back bypass all top-level interception (both onEnter and legacy adapter) and only run route-level redirects for state updates? (This avoids “block on back” weirdness.) Preference?

We should still run onEnter when restoration, because the onEnter can be Auth or something that prevent viewing based on session. We can't skip them.

First on enter behavior can completely STOP and do nothing at all using Block

This is a good point. I almost feels like Block has to have at least a .go. doing nothing will be the same as a go(prev.uri).

This will look weird with methods like pop specially with iOS back button behavior

Can you explain a bit more?

@omar-hanafy
Copy link
Contributor Author

This is a good point. I almost feels like Block has to have at least a .go. doing nothing will be the same as a go(prev.uri).

Instead of forcing a redirect on Block(), what if we add a convenience factory: Block.go(location) for the common "block and reroute" case? This keeps Block() as a pure veto for when we truly need to stop navigation without side effects.

Can you explain a bit more?

You're right, my concern was misplaced. I was incorrectly equating pop with state restoration. I've since confirmed:

  • pop doesn't use the parser and won't trigger onEnter. Intercepting it is a job for onExit.
  • restore does use the parser, so onEnter must run to re-validate the state (e.g., auth).

I'm aligned with your proposal now. Here's my plan for the next commit:

  • Unify Guards: Adapt the legacy top-level redirect into the onEnter flow.
  • Fix Restoration: Ensure onEnter (with the redirect adapter) runs during state restoration.
  • Implement Block.go(): Add the convenience factory if you agree with me.
  • Update the Tests

@omar-hanafy
Copy link
Contributor Author

Hi @chunhtai I did adapt legacy top-level redirect into onEnter flow:

  • Order: onEnter → legacy top-level redirect (once) → route-level redirects.
  • restore now re-validates via onEnter.
  • Top-level redirect extracted to applyTopLegacyRedirect.
  • Added RouteConfiguration.normalizeUri; used across parsing/redirects.
  • Tests updated to cover order, restore behavior, limits, and error reporting.

@cedvdb
Copy link
Contributor

cedvdb commented Aug 27, 2025

doing nothing will be the same as a go(prev.uri).

Doing nothing should do nothing, not even a refresh otherwise you break the (implicit) contract that it should just stop navigation.

Instead of forcing a redirect on Block(), what if we add a convenience factory: Block.go(location) for the common "block and reroute" case?

IMO this is not necessary and may even be confusing. Block(then: ...) is clearer

@omar-hanafy
Copy link
Contributor Author

@chunhtai

Note that I didn’t wrap redirect as an onEnter adapter (go()+Block) because its semantics differ (history/limit/loop detection and restore).

Instead, I run legacy top redirect once right after onEnter allows, via applyTopLegacyRedirect, sharing redirectHistory with route-level redirects.

Parser now sequences: onEnter → legacy top redirect (once) → route-level redirects. RouteConfiguration.redirect handles only route-level; normalizeUri keeps paths stable.

@chunhtai
Copy link
Contributor

@omar-hanafy I have 2 questions

  1. Can you talk a bit about what semantics difference they have?

  2. If there is indeed a semantics difference and without a migration path to onExit, what's our current plan for toplevel redirect? are we keeping it around or are we doing a no-longer-supported-behavior breaking change

@omar-hanafy
Copy link
Contributor Author

Can you talk a bit about what semantics difference they have?

I tried implementing top‑level redirect as onEnter + Block(then: go(...)), but several tests failed because the two mechanisms don’t have identical semantics.

  1. Top-level redirect rewrites the target in the same parse cycle, while onEnter + go() is a two-step flow that changes history behavior.
  2. top-level redirect runs only once and avoids re-evaluating if the target itself triggers another top-level redirect. The onEnter adapter, however, re-parses and re-runs interception.
  3. The legacy redirect system (top-level and route-level) shares a unified redirectHistory to detect redirect chains (e.g., A → B → A). The onEnter guard has its own history to prevent loops from navigation calls made within the guard itself. An adapter can't easily unify these two distinct mechanisms.
  4. With legacy behavior, validation happens via onEnter and then the top-level redirect runs once before route-level redirects, keeping branch stacks/URLs correct. The adapter adds an extra navigation after the block commits.
  5. onEnter.then is deferred and reported without undoing navigation, while legacy redirect errors surface immediately in-cycle as an error RouteMatchList.

To maintain stability while redirect is deprecated, it’s still supported internally as a one-shot stage (applyTopLegacyRedirect) that runs after onEnter allows and before route-level redirects, sharing the same redirect history for loop detection.

If u see this differently, let me know ur thoughts.

If there is indeed a semantics difference and without a migration path to onExit, what's our current plan for toplevel redirect? are we keeping it around or are we doing a no-longer-supported-behavior breaking change

I think:

  • We keep the top-level redirect as a deprecated API, maintaining its exact runtime semantics. This PR introduces no breaking change.
  • provide a migration guide and add linting to help users move to the onEnter + router.go() pattern. onExit is a leave-guard and is not part of this migration path.
  • The redirect API will be fully removed in a future major release, at which point its behavior will no longer be supported

@chunhtai
Copy link
Contributor

chunhtai commented Sep 19, 2025

Top-level redirect rewrites the target in the same parse cycle, while onEnter + go() is a two-step flow that changes history behavior.

ah it is because the go needs to wait for one frame to register. This is not good. I have thought about moving the parsing logic to the router delegate instead of in route parser. Originally i thought put it in routeinformationparser is a good idea, now not so much.

Let's forget reimiplementing the top level redirect for now, what do you think that an onEnter that redirect multiple time in one route change creates multiple browser history entries. or do you think it should only generate one entry that contains the redirecting result?

I personally prefer the latter, but would like to hear your thought

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.

7 participants