-
Notifications
You must be signed in to change notification settings - Fork 26
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
Multiple navigate handlers, and withholding the 'navigate' event? #89
Comments
Is the intention that other routes on the page be unaware that they are being embedded in another router, and use the native API as if they were a top-level router? We've kicked around in this design space for a fair bit of time, and couldn't come up with either a killer use case or a killer API. I think the canonical issue where we discussed this was #18, but it's stagnated a bit. I do think before we can consider this API "finished" we need to think about this more deeply to see whether there's something simple usability wise we can do to make this possible. In your example it seems like you're okay leaking state and URL between routers. That is, one router could read another routers URL parameters. But I don't think in general that would be desirable, and I think a successful design here would probably allow for a more careful segmentation of the URL/state space. Do you know which routers care about which URL parameters and state keys? Do two routers ever piggyback on the same history entry (that is, they send requests through a central router and those get serialized into one history.push)? |
Yes - all major library routers (Angular, Vue, React, Svelte, etc) work in the single-spa ecosystem, and they don't have to do anything different on their side of things. Additionally, they can coexist at the same time on the same page in some situations.
We actually quite explicitly want multiple routers to know when the URL changes, and what it changed to, at the same time. It's possible (though only recommended in very specific situations due to performance) that multiple routers (and possibly even other frameworks!) are active on the page at a time, and they need to know when the url changes so they can render their content for that URL. But I could be misunderstand what you mean when you say "more careful segmentation of the URL/state space." Sorry if I am! 😊
With single-spa, you generally write very broad URL matches in your activity function / activeWhen config and then the application routers handle the deeper routes; e.g. you tell single-spa the app is active when the path starts with
Unknowingly, yes. Because single-spa monkeypatches things like history.pushState and history.replaceState and popstate and hashchange to achieve this. Hopefully I've understood your questions and answered them well enough. Thanks. |
Ah so you would never have multiple routers active on the same route? I think there's a few use cases to consider:
I think case #2 might be generally solvable by doing route guards at the top of a navigate-handler (without any new API additions), but I don't fully understand the details of single-spa's use case. Is this something where I should go and read your project documentation and/or code, or do you think you could summarize the model succinctly here? |
Actually, yes it's possible. Not as common, but it does happen.
Yeah. single-spa works in both 1 & 2 situations. 2 is more common, but there are cases where 1 happens as well.
I can try to explain, if that helps! Feel free to ask about things I may have missed or you have questions on, though. single-spa is a "router for frontend microservices." A really overloaded term which can mean a lot of things; for single-spa, all it really means is that we enable you can separate parts of your application into different pieces, which can all be independently deployed but still composed together in the browser as if it were a monolithic build. Here's an example website using single-spa + Vue along with the source-code for it, but a high-level overview is this:
In summary, single-spa acts as a parent router, and at any time there may be n number of applications active on a single given page/URL, and therefore n number of routers active as well. I tried to keep this pretty high-level, but let me know if you would like details in any area. I hope that overview helps. 😊 |
@domenic This brings up a couple of salient points for me:
I think you're right here Anthony, and we should consider a new API addition, and I think it's precisely what you've proposed. You called it appHistory.addEventListener('navigate', (e) => {
if (!e.canRespond) return;
e.blockUntil(singleSpa.triggerAppChange());
}); This would be separate from Personally I think it's a little disappointing that we can't just use the return value of an event listener to delay the calling of the next event listener, but that ship might have long since sailed. But imagine: appHistory.addEventListener('navigate', async (e) => {
if (!e.canRespond) return;
await singleSpa.triggerAppChange();
}); Feels pretty elegant, and makes the "pause" and "resume" implicit. I wonder if it's a little confusing if you combine Promise return (or appHistory.addEventListener('navigate', async (e) => {
if (!e.canRespond) return;
e.respondWith(Promise.resolve());
await singleSpa.triggerAppChange();
}); I think the reasonable behavior here is that the navigate is not "finished" until all respondWith() promises have resolved and every promise returned from an event listener (or |
FWIW I think there's a reasonable other path that we could consider going down (in addition, or instead of), and it's to register event listeners for only a subset of URL/state parameters. This is more along the lines of what I was describing in #18 appHistory.registerScope('foo', {urlKeys: ['a', 'b', 'c']});
const scopedAppHistory = appHistory.getScope('foo');
scopedAppHistory.addEventListener('navigate', (e) => {
// Called whenever an entry added by this scopedAppHistory is involved in a navigate.
});
// At this point, the only thing in scopedAppHistory.entries() is the root entry.
// This only updates the URL keys that were registered, and the state does not overwrite the global appHistory state.
// appHistory gets an update about this, as if it were a normal URL change. I think scoped navigate handlers would be
// triggered first? But maybe they don't interact?
// Instead of passing a full URL, you pass a Map with url keys and values.
scopedAppHistory.navigate(urlParams: Map<string, string>, {state}); |
It kind of depends on what you mean.
The latter is how we've mostly designed this API. But saying
indicates it probably won't work for your case.
I've never had a strong attachment to the name respondWith(). Indeed you don't supply an actual
Well, from my perspective we've considered it, and decided it's rare that it would work in a useful way :). Service workers historically went through a similar thing; they want to use some features and semantics of events, but they don't really work that well with multiple listeners. They ended up settling on events anyway. /cc @annevk To answer your specific questions:
As currently specced, calling respondWith() or preventDefault() means that any further calls to respondWith() will fail with an "InvalidStateError" DOMException.
We could spec that, and it might be a good idea.
Multiple event listeners are always called synchronously; this is a feature of all
To me they seem analogous: in both cases, if you have two parts of your app trying to handle navigations or fetches, something has gone wrong.
Can you explain what this would do? Recall that we can't actually delay the navigation; we need to update So, would the semantics here be something like
My general suggestion is that frameworks should try to move this down a level, and have only one I realize that might be difficult for single-spa though if they want to work with unmodified third-party routers. Then again, it sounds like maybe they can make it work via the power of monkeypatching?
Service workers has struggled with this for years, and unfortunately not gotten anywhere. Their issue is w3c/ServiceWorker#1373. That said, their constraints are quite different (e.g. for them they want to avoid running any JS code at all), so maybe there's something we could do which works better. I do think it's tricky though; it feels more like a framework-level solution that I'd prefer to add later after seeing what frameworks come up with themselves. |
I know the above comment wasn't addressed to me, but I put my thoughts on it below. Hopefully that's ok; I don't mean it to be speaking on Tom's behalf - I'm sure he'll have his own answers/responses - it's just me sharing my thoughts.
Yes, agreed.
Oh I had no idea on this - is this in the official spec? Because I think I've missed it in the explainer doc + examples if it's in there. I was always under the assumption that every handler could call This behavior actually seems like it could open the door to some brittleness - router libraries could reasonable assume that you can just call
Yeah, that can help with the brittleness I mentioned above. Or another option would be to set
This seems slightly weird to me - that you set this event up just like any other EventTarget where multiple handlers can be registered, but then at the same time kind of say that you really only support one handler? Or am I misunderstanding you here? I also think
Yes, I think (in an ideal, perfect world) that it would act this way. But I don't know if that's something that could reasonably happen or not.
Yeah, again, if that's what it comes down to, then I think that'll be ok. We just thought it would be nice to at least have this conversation and see if there is a "better" way to do this. Thank you for your response and time! |
The more I've thought about this part, the more I disagree with it. It essentially puts us back to the current status quo, which is that only one router per page is allowed and any third party code must somehow tie into that router. To not clutter this discussion about the [edit] reading your response below now - we posted nearly at the same time :D |
Yeah, see https://wicg.github.io/app-history/#dom-apphistorynavigateevent-respondwith Your reply has some other good points but let me take a step back and try to summarize.
|
I largely agree with Domenic's assessment here.
I think it's probably more common for there to be multiple routers on a SPA than we might expect. And perhaps additionally there's something to be said, even in applications that are in control, for having a non-monolithic event listener. But I do think that this is the struggle with this feature - we need to make sure this use case is real, rather than specialized.
If we go down this route, I think a more fruitful direction would be a beforenavigate listener, rather than making navigate itself asynchronous and only certain APIs would trigger the beforenavigate (e.g. location.href would not). There are some cases where it's probably okay for the navigate to be asynchronous, but lots of cases where it's probably not. Of the smaller QOL improvements: I think allowing multiple calls to
Oh, this seems like something we'd want to do, regardless of the outcome here. |
slight correction here: single-spa can synchronously determine which apps should be active. However, it then needs to be able to make async changes (e.g. dynamically import an app) before other navigate handlers are notified.
I think this is largely a chicken-and-egg problem; the vast majority of cases you’ll find that only one router is on the page. But if you dig into WHY there’s only one router, a lot of the reasons will come down to “there’s no easy way to have multiple routers on the page at the same time.” Allowing multiple routers can open the door to much easier 3rd party integrations; think of a Widget or Custom Element that perhaps need to know the route (and maybe occasionally respond with their own promise to get more data). As well as a single-spa situation that has multiple applications at the same time. |
Forked the discussion about allowing multiple routers/event handlers to #94; I think that is a valuable and important use case, whereas the |
I have made a library which listens on the navigate event and I would like to see that it will receive the event regardless what the application which uses the library does and/or what order the events are listened. |
I think there's still a discussion to be had around how exactly multiple routers on the page should function if they are nested within the same-document, and a navigate should update multiple parts of the page. |
Context/Background
As a maintainer of single-spa, we've had a small discussion about what an "appHistory" version of single-spa would look like.
For context, single-spa is probably pretty unique among JavaScript routers - it acts as a parent router and almost always has multiple children/application routers beneath it. As a part of that structure, single-spa must monkey-patch global functions like
popstate
(see the section titled "What may be missing/difficult still?" in the discussion above for more detail) so that it can ensure that children/applications don't do unnecessary work for a route that they're not supposed to see.In other words, single-spa must asynchronously ensure that all applications have been "unmounted" and/or "mounted" before it releases the navigation event to the children/application routers. It does that by monkeypatching those event handlers, and withholding the event from firing until it's sure that children/application are ready.
Proposal
Back to appHistory - we were wondering if there could potentially be a way to "withhold" a navigation event from propagating to other handlers until a later time, ideally after a promise resolves?
For example, maybe it looks like:
But I have no idea if that is feasible/possible or not.
Or maybe there's a better way to do something like this?
Workaround without monkeypatching
Without such a capability, one idea I've thought of is the following:
Cancelling the navigate event, rolling back (potentially using #86 ?), waiting for the single-spa's
triggerAppChange()
promise to resolve, and then navigating again to the original URL?However, one downside of this approach is that single-spa itself can't use
evt.respondWith()
(unless it would work to pass a promise to that and then cancel the navigate event? I'm not sure) which means that browser UI and assistive technology would be left in the dark.Otherwise, I think we would be left with monkeypatching the
navigate
event - which isn't a horrible thing, since it's what we have to do currently. But we thought it would be potentially useful to talk about it.Thanks 😊
The text was updated successfully, but these errors were encountered: