Skip to content

[go_router] Support for top level onEnter callback. #8339

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 36 commits into
base: main
Choose a base branch
from

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

@Deprecated(
'Use onEnter instead. '
'This feature will be removed in a future release.',
)
final int redirectLimit;
Copy link
Contributor

Choose a reason for hiding this comment

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

This is to handle infinite redirect, I thought we may still want to keep this?

Copy link
Contributor

Choose a reason for hiding this comment

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

@chunhtai Is there any reason to have this public ? An arbitrary limit could be picked, eg: max 5 times on the same route and kept internal.

Copy link
Contributor

Choose a reason for hiding this comment

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

we let people pick their own limit, it was here since the v4 before flutter team took ownership. I find no reason to deprecate it, so it has been hanging around since.

);

/// 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

@cedvdb, thanks for the comment and the PR!

I saw the issue you encountered in your PR. I've pushed some updates for the onEnter feature based on further development and testing, which should address that and other related points.

Here's what I did:

  1. I've created a new test file specifically for this feature (on_enter_test.dart) and moved the existing onEnter cases there, along with adding many new ones.
  2. The specific test case you highlighted (Should allow redirection with query parameters, has been refactored and fixed. The original failure often stemmed from tricky state conflicts when navigating from within onEnter (e.g., pushNamed) while also returning false to block the original navigation that triggered the onEnter. The updated pattern in the test uses a non-awaited navigation call immediately followed by return false, which resolves the state conflict and should now work correctly.
  3. Fixed a critical bug where exceptions thrown directly inside the user's onEntercallback weren't being properly caught by the framework and routed to GoRouter.onException. The parser now correctly handles these, and there's a test case specifically for this ('Should trigger onException for user exception in onEnter').
  4. Added tests for various other scenarios, including using go, goNamed, push(including awaiting pop results), replace, pushReplacement from within onEnter, and the internal onEnter loop detection.

Take another look at the latest changes, especially the tests in on_enter_test.dart? Hopefully, this addresses the issues and makes this PR ready for a merge!

@cedvdb
Copy link
Contributor

cedvdb commented Apr 8, 2025

The updated pattern in the test uses a non-awaited navigation call immediately followed by return false, which resolves the state conflict and should now work correctly.

Isn't that unintuitive for the end users ? Can you explain what is the issue when await is used ? I suppose it's complex, ence why you had to modify the test

@omar-hanafy
Copy link
Contributor Author

Can you explain what is the issue when await is used ? I suppose it's complex, ence why you had to modify the test

I think I miss explained it, my previous explanation about the state conflict being inherent to await + return false wasn't quite accurate.

I meant to explain why I thought there was a problem with the test case u mentioned.

it turns out the original issue was not with using await navigation() followed by return false; itself. The actual root cause was that in those complex navigation sequences (especially when navigating from within onEnter), the currentState and nextState being passed into the onEnter callback were not being determined correctly.

developers can confidently use await within onEnter.

I have updated the test case again. It is actually using await with pushNamed but I made its logic closer to the one u created, juts a little bit enhanced.

another thing I noticed in the failing test case in ur PR I added a comment there explaining why I changed this in the updated test case as well.

@cedvdb
Copy link
Contributor

cedvdb commented Apr 9, 2025

The actual root cause was that in those complex navigation sequences (especially when navigating from within onEnter), the currentState and nextState being passed into the onEnter callback were not being determined correctly

I'm confused, could you clarify if you did fix the issue where currentState was determined incorrectly or if you only changed the test ?

@omar-hanafy
Copy link
Contributor Author

could you clarify if you did fix the issue where currentState was determined incorrectly or if you only changed the test ?

Enhanced the test, and fixed the issue where currentState was determined incorrectly.

@omar-hanafy
Copy link
Contributor Author

@cedvdb any updates?

@cedvdb
Copy link
Contributor

cedvdb commented May 10, 2025

Edit: nvm

@feinstein
Copy link
Contributor

@cedvdb would you be able to share what are the complex scenarios you are using it?

@cedvdb
Copy link
Contributor

cedvdb commented May 21, 2025

@omar-hanafy you may want to request reviews from the flutter team

@omar-hanafy
Copy link
Contributor Author

omar-hanafy commented May 30, 2025

@cedvdb

I've thoroughly investigated your concern about currentState.uri being "seemingly wrong" and want to clarify how it works:

How currentState works in onEnter:

// When navigating from /home to /about:
onEnter: (context, current, next, goRouter) async {
  // current.uri = "/home"     <- where we are now (before navigation)
  // next.uri = "/about"       <- where we're trying to go
  // goRouter.state.uri = "/home" <- also the current location
  
  return true; // Allow navigation
}

And we actually have several test cases that verify this exact behavior. The most relevant one is the 'Should handle go usage in onEnter'.

The currentState parameter represents the actual current location before navigation happens. This is intentional and follows the navigation guard pattern used in other routing libraries (Vue Router, Angular Router, etc.). The onEnter callback executes before the navigation occurs, so it can decide whether to allow or block it.

Testing:
I've run all test cases (12 in total) and they all pass, including tests for:

  • Simple navigation flows
  • Query parameters and fragments
  • Push/pop operations
  • Shell routes
  • Navigation blocking scenarios
  • Async operations

I also commented in you prev PR about that case and clarified whats done in the last push here.

Could you help me understand your specific issue?
To better understand what you're experiencing, could you please:

  1. Share the test case you mentioned that would have caught the issue
  2. Describe what behavior you expected vs what you're seeing
  3. Provide a minimal code example that demonstrates the problem

This will help me determine if there's an edge case I haven't considered or if it's a documentation issue that needs clarification.

@chunhtai whats ur review on this btw ?

@cedvdb
Copy link
Contributor

cedvdb commented May 30, 2025

@omar-hanafy

I reran the test suite and it is now passing. I'm not 100% sure because I cannot run the whole test suite, but as far as I remember, the problematic test is now fixed.

It doesn't seem like you made changes lately that should affect that, so it may be that the previous version from the cache before you fixed the issue was used on the last run.

In other words, for me, it seems to work as intended now

@omar-hanafy
Copy link
Contributor Author

@cedvdb perfect!
What's left for this PR to be merged now?

@cedvdb
Copy link
Contributor

cedvdb commented May 31, 2025

@omar-hanafy you probably want to reach on discord to ask for a review from the flutter team

@Piinks Piinks removed the request for review from cedvdb June 10, 2025 19:55
@Piinks
Copy link
Contributor

Piinks commented Jun 10, 2025

@chunhtai can you take a look?

@Piinks
Copy link
Contributor

Piinks commented Jun 10, 2025

@omar-hanafy it looks like there are some merge conflicts.

@omar-hanafy
Copy link
Contributor Author

@Piinks It was just from a latest version update (In the changelog), probably from another PR, the conflicted files are resolved now.

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.

6 participants