-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
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
Rewrite router more #1860
Rewrite router more #1860
Conversation
I like the direction in which this is going but there a few things worth discussing:
I like the CC: @siku2, thoughts on this? |
Thanks for reviewing! Here are my thoughts on each of the points you raised:
Whilst agents can be more verbose to interact with, you don't need to interact with either of them directly to use the router. You would use the I think there are some options for improving the API though: for example, it might be nice to be able to initiate a router action without first constructing a
That's already supported with |
Why not put the state in the
It wouldn't hurt to provide a component interface for
I would benchmark both implementations to see the differences and then decide if we should keep using regex or not. Also, it might be faster to use DOM API of regex instead of a Rust implementation (not fully sure though).
Great |
There isn't just one You said originally:
Are you referring to complexity and boilerplate in user code, or complexity and boilerplate within the implementation of yew-router itself?
Sure.
AFAIK, there's no JS equivalent to |
I mean replacing
User-facing code for the most part. The implementation also gets complex but that isn't a that big of a deal as long as there are good and concise abstractions over it.
I would say, it's still worth benchmarking if adding regex improves performance, and by how much. This is an additional dependency which will also increase final binary size. |
Hmm, OK. (BTW, this PR does allow nested routers as per #1853, but there is still only one The The only downside of agents I see is that you need to create a bridge before sending a message, even in cases where you don't need to receive messages from the agent. Is that the complexity you don't like?
They are currently public, but I don't expect users to need to use them directly, and would be happy to make them private. The
Binary size and startup cost seem much more likely to be potential downsides to the regex approach. |
It is a part of it. There's also cost associated with creating a link to agents. Also, afaik, we need to use Also, the current API is pretty confusing. We construct a router but why do we have to pass a From this implementation
From previous implementation (#1791)
I'm not really sure about what to do with state. It seems like it's pretty hard to handle it efficiently (without
Those are pretty major downsides if you ask me. |
I'm not sure I understand? From the user's perspective they just use
The callback is used to respond to route changes. For classical components, that means triggering an With
First of all, it seems quite error-prone. If I call Secondly, the Thirdly,
I agree with this too, but Rust is about correctness: if there is subtlety to a task, then it's better to make the user think about that subtlety. In this case the subtlety is "do you really want the route at the time your component was constructed, or do you actually want the current route at any given time. Also, this is how: let router = use_router();
// Get the current route
let current_route = router.route(); // Automatically updates when the route changes.
// Set the current route
router.push(new_route); |
I misunderstood. I thought agent was to be used the user.
Why should it?
It gets detached automatically when the handle is destroyed. Internally, the handle is just an opaque wrapper to hide the the gloo's
I'm not sure what
That could be easily fixed by adding a
A hook to render the component on route change and give the route could be easily implemented like: fn use_route<R: Routable + 'static>() -> &R {
let route = use_state(|| None);
use_effect(|| {
let handle = attach_route_listener(Callback::from(move |new_route| route.set(new_route)));
|| drop(handle)
})
&*route
} (not sure if this snippet is fully correct and compiles but you get the idea of what i'm trying to say) I didn't add this in the PR because I wanted to avoid dependency on |
I know that, but I think it's easy to misuse, especially as users might expect the component to get re-rendered when the route changes anyway.
Ah OK. That definitely makes it more usable.
Here's a small example: // Top-level routes
#[derive(Routable, PartialEq, Clone, Debug)]
pub enum Routes {
#[at("/")]
Home,
// Delegates everything under `/settings` to the `SettingsRoutes` type.
#[at("/settings")]
Settings(#[bind(rest)] SettingsRoutes),
}
// Routes within the "Settings" application.
#[derive(Routable, PartialEq, Clone, Debug)]
pub enum SettingsRoutes {
#[at("/")]
List,
#[at("/account")]
Account,
#[at("/notifications")]
Notifications,
}
#[function_component(App)]
fn app() -> Html {
let router = use_router();
// This is called when the user navigates *within* the settings application in order to map the
// `SettingsRoutes` back to a `Routes` type.
let on_settings_route = router.dispatcher(|sub_route| Some(sub_route.map(Routes::Settings)));
match &*router.route() {
Routes::Home => {...},
// Mount the entire "Settings" application under this route
Routes::Settings(sub_route) => html! {
<Mount route=sub_route onroute=on_settings_route>
<Settings />
<Mount>
}
}
}
#[function_component(Settings)]
fn settings() -> Html {
let router = use_router();
match &*router.route() {
// Here you can see that the "Settings" application doesn't need to understand its place in the application.
// It can directly create links to `SettingsRoutes`. The parent `Mount` component will take care of the mapping.
// This `Settings` application could even be mounted at multiple routes within the application.
SettingsRoutes::List => html! {
<Link route=SettingsRoute::Account>Account</Link>
<Link route=SettingsRoute::Notifications>Notifications</Link>
},
SettingsRoutes::Account => {...},
SettingsRoutes::Notifications => {...},
}
} |
I don't think so. It's supposed to be called in
Also, I'd be happy to work with you on updating my implementation with the features I described so we can have the router provide features without unneeded complexity in the implementation. |
OK - but I think it's important that the "reactive" option is as easy to use as the "non-reactive" option.
Please go ahead! As mentioned, the Supporting
I'm not sure how that would work - one of the reason I use an agent is because it manages the startup and shutdown of the router automatically. I'm guessing you would say to reintroduce a |
I think it is, for struct components. For function components, adding a hook would make it easier.
I was thinking of adding a function in thread_local!(pub static ROUTE: Option<TheRoutableEnum> = None);
// ...
fn route() -> Option<Self> {
ROUTE.with(|route| match route {
Some(route) => route,
None => Self::recognize()
})
} and we update the |
There are two downsides to that which I wanted to avoid:
With this PR, you can do stuff like: #[derive(Routable, PartialEq, Clone, Debug)]
pub enum Routes {
#[at("/")]
Home,
#[at("/resources/:id")]
Resource {
id: Uuid,
#[bind(rest)]
sub_route: ResourceRoutes,
}
} Or, let's say you want to still have "application-wide" query parameters, you could use: #[derive(Routable, PartialEq, Clone, Debug)]
pub enum Routes {
#[at("/")]
All {
#[bind(rest)]
sub_route: AppRoutes,
// This query argument is extracted regardless of what route we're visiting
#[bind(query_arg)]
token: String,
}
}
#[derive(Routable, PartialEq, Clone, Debug)]
pub enum AppRoutes {
#[at("/")]
Home,
...
} |
While avoiding that, we end up adding this line (taken from a previous comment of yours): // This is called when the user navigates *within* the settings application in order to map the
// `SettingsRoutes` back to a `Routes` type.
let on_settings_route = router.dispatcher(|sub_route| Some(sub_route.map(Routes::Settings))); I'm not sure if that's worth the trade-off. Also, I'd appreciate if you could provide some implementation details if something like this were to be implemented for the current router |
True, but that line makes it considerably more powerful, as you can map the sub-route to the parent however you want (the parent doesn't even necessarily have to store the sub-route, or it could take the entire sub-route and store it as a query parameter or even state). For the simple case of a new-type wrapper (which is the only kind a |
True. Do you know of any way to implement |
The main issue is that There is one other option: we could keep a map of "Route type -> Router" in a static variable. When a The downside to this approach is that it "leaks" outside of the
With the current PR you could actually model that with a recursive enum, whereas it wouldn't work with the static map example. |
That isn't really an issue. We can add a wrapper component that provides us the context without a |
You can have a
All the changes in this PR were necessary to support the features it adds, it's not like I just changed things for fun 😄. Whilst I think there is room for improvements to the surface API, you would quickly find if you attempted to add the same features to the existing implementation, without making these same changes, that it just doesn't work, or else comes with significant downsides. Things that could be improved in the surface API:
Aside from the surface API, I could maybe work towards an "Agent-lite" concept - it would work like an agent in that you could "subscribe" to it (to ensure things are cleaned up correctly), but access (ie. getting current route, or pushing a route) to it could work more like a service. |
@hamza1311 I started migrating stuff to the "Agent-lite" model I described earlier. You can see an example of specifically the struct HistoryState {
last_route: Rc<Route>,
subscribers: Vec<Callback<Rc<Route>>>,
cb: Closure<dyn Fn()>,
}
thread_local! {
static HISTORY_STATE: RefCell<Option<HistoryState>> = RefCell::new(None);
}
/// Service to interface with the history API.
#[derive(Debug)]
pub struct HistoryService;
impl HistoryState {
fn with<R>(f: impl FnOnce(&mut Option<Self>) -> R) -> R {
HISTORY_STATE.with(|state| f(&mut *state.borrow_mut()))
}
fn determine_current_route() -> Route {
let window = window();
let history = window.history().expect("no history");
let location = window.location();
let path = location.pathname().expect("no pathname");
let query = location.search().expect("no pathname");
let hash = location.hash().expect("no pathname");
let state = history.state().expect("no state");
let mut res = Route {
path,
query,
hash,
state,
};
res.unapply_base();
res
}
fn new() -> Self {
// Install event listeners
let cb: Closure<dyn Fn()> = Closure::wrap(Box::new(Self::update));
let et: EventTarget = window().into();
et.add_event_listener_with_callback("popstate", cb.as_ref().unchecked_ref())
.expect("add popstate listener");
et.add_event_listener_with_callback("hashchange", cb.as_ref().unchecked_ref())
.expect("add hashchange listener");
Self {
last_route: Rc::new(Self::determine_current_route()),
subscribers: Vec::new(),
cb,
}
}
// We sometimes return a function to run when the state is not borrowed.
// This is so that callbacks don't panic if they try to access the state.
fn update_inner(maybe_state: &mut Option<Self>) -> Option<impl FnOnce() + 'static> {
let state = maybe_state.as_mut()?;
if state.subscribers.is_empty() {
*maybe_state = None;
None
} else {
let route = Rc::new(Self::determine_current_route());
if state.last_route != route {
state.last_route = route.clone();
let subscribers = state.subscribers.clone();
Some(move || {
for subscriber in subscribers {
subscriber.emit(route.clone());
}
})
} else {
None
}
}
}
fn update() {
if let Some(f) = Self::with(Self::update_inner) {
f();
}
}
fn current_route(maybe_state: &mut Option<Self>) -> Rc<Route> {
maybe_state.get_or_insert_with(Self::new).last_route.clone()
}
fn register(maybe_state: &mut Option<Self>, callback: Callback<Rc<Route>>) -> HistoryListener {
maybe_state
.get_or_insert_with(Self::new)
.subscribers
.push(callback.clone());
HistoryListener(callback)
}
fn unregister(maybe_state: &mut Option<Self>, callback: &Callback<Rc<Route>>) {
if let Some(state) = maybe_state {
if let Some(index) = state
.subscribers
.iter()
.position(|subscriber| subscriber == callback)
{
state.subscribers.remove(index);
}
}
}
}
impl Drop for HistoryState {
fn drop(&mut self) {
let et: EventTarget = window().into();
et.remove_event_listener_with_callback("popstate", self.cb.as_ref().unchecked_ref())
.expect("remove popstate listener");
et.remove_event_listener_with_callback("hashchange", self.cb.as_ref().unchecked_ref())
.expect("remove hashchange listener");
}
}
pub struct HistoryListener(Callback<Rc<Route>>);
impl Drop for HistoryListener {
fn drop(&mut self) {
HistoryState::with(|state| HistoryState::unregister(state, &self.0));
}
}
impl HistoryService {
pub fn dispatch(action: HistoryAction) {
let history = window().history().expect("no history");
match action {
HistoryAction::Push(mut route) => {
route.apply_base();
history
.push_state_with_url(&route.state, "", Some(&route.url()))
.expect("push history");
// Not triggered automatically by `pushState`.
HistoryState::update();
}
HistoryAction::Replace(mut route) => {
route.apply_base();
history
.replace_state_with_url(&route.state, "", Some(&route.url()))
.expect("replace history");
// Not triggered automatically by `replaceState`.
HistoryState::update();
}
HistoryAction::Back => history.back().expect("back history"),
HistoryAction::Forward => history.forward().expect("forward history"),
HistoryAction::Go(index) => history.go_with_delta(index).expect("go history"),
}
}
pub fn push(route: Route) {
Self::dispatch(HistoryAction::Push(route));
}
pub fn replace(route: Route) {
Self::dispatch(HistoryAction::Replace(route));
}
pub fn forward() {
Self::dispatch(HistoryAction::Forward);
}
pub fn back() {
Self::dispatch(HistoryAction::Back);
}
pub fn go(index: i32) {
Self::dispatch(HistoryAction::Go(index));
}
pub fn current() -> Rc<Route> {
HistoryState::with(HistoryState::current_route)
}
pub fn register(callback: Callback<Rc<Route>>) -> HistoryListener {
HistoryState::with(|state| HistoryState::register(state, callback))
}
} LMK what you think. I plan to make a similar change to convert |
Alright, I've made that change, so both |
That looks better. From a quick glance, there's 2 things that stand out:
|
LMK what you think now. I considered making the functions on |
Looks to me like no one is working on this PR directly, so I will close it. Please notify me if im wrong, i can reopen then. |
Description
This implements the ideas I described here: #1791 (comment)
Core changes
Route
struct.HistoryAgent
is implemented which binds the browser's history API to a component via thisRoute
struct. This can be used on its own without the higher levelRoutable
API if desired.Routable
trait is now simply converts to/from aRoute
, and is implemented byRoute
.Router
struct is implemented. This is not a component, it acts more like aBridge
to theHistoryAgent
, but generic over aRoutable
type. It can be used from a functional component via theuse_router
hook, or from a classical component viaRouter::new
.Router
acts as a two-way binding between the component and the current route: it allows accessing the current route, and it allows changing the current route.Router
can be overridden via the context mechanism, allowing an entire application to be "mounted" within another, without the inner application needing to be aware of this.Example of using the router from a functional component:
Derive macro changes
#[not_found]
with just aDefault
implementation as it allows the user to specify specific field values.#[bind(rest)]
binding mode, which means the route does a prefix match, and the rest of the route (including any unmatched query arguments, state, hash, etc.) are decoded into the corresponding field, which is itself aRoutable
type.#[bind(query_arg)]
,#[bind(hash_arg)]
,#[bind(pop_state)]
modes, which allow extracting arguments from the query string, hash string, and state. More binding modes can easily be added (eg.#[bind(hash)]
which could just extract the entire hash without attempting to decode it into individual arguments.Example of deriving
Routable
:General fixes
There are some issues with the current approach to routing: see https://github.com/yewstack/yew/blob/master/examples/router/src/pages/post_list.rs#L31 - because the current page is managed entirely within the component state, the component cannot react if the page number is changed directly. The "next page" button relies on being able to directly send this message.
(This is still very much a draft)
Checklist
cargo make pr-flow