Open frehner opened 3 years ago
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)?
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?
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.
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.
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! 😊
Do you know which routers care about which URL parameters and state keys?
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 /calendar
and then the application router can handle /calendar/day
and /calendar/week
. If that makes sense?
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)?
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?
Ah so you would never have multiple routers active on the same route?
Actually, yes it's possible. Not as common, but it does happen.
I think there's a few use cases to consider:
- One page, multi-router. This is where some sub-DOM wants to be a router, but isn't the root, kind of like an iframe.
- Multi-page (same origin), multi-router. This is where different pages may have different routers but they're all the same origin.
Yeah. single-spa works in both 1 & 2 situations. 2 is more common, but there are cases where 1 happens as well.
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?
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:
/view-doggos
, and the rate-dogs app is active on /rate-doggos
.history.pushState()
). It then checks each application to see if it should be "active" on the upcoming url or not; it will dynamically import it if it should, and it will unmount it if it shouldn't. 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:
FetchEvent
that has respondWith
actually resolves a value, whereas the promise passed to NavigateEvent.prototype.respondWith
does not. I wonder whether waitUntil
would be a better corollary, since we're telling the browser that the navigate should be extended, rather then that we're changing the navigate resolved value itself. Or since neither of these are a precise fit, maybe we should use a different name altogether?navigate
event listeners. It seems reasonable that the listeners would be called in the order in which they were registered. But what happens if an earlier event listener calls respondWith
? Should the later event listeners not get the navigate event at all? Should they be called synchronously, or after the respondWith
resolves? Should they be called asynchronously after the respondWith
resolves? I think the prior art here with the FetchEvent
is that all event listeners are called synchronously, regardless of what the previous handler did. And I think unlike the fetch API, it's reasonable to assume that someone could accidentally load two sets of code that both register navigate
handlers.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 withhold
but I'm wondering whether we should use the term block
because it clearly shows that something is being delayed, and has precedence in other systems (like Node). Specifically:
appHistory.addEventListener('navigate', (e) => {
if (!e.canRespond) return;
e.blockUntil(singleSpa.triggerAppChange());
});
This would be separate from respondWith
(or whatever we call it) which is a signal to the browser about the duration of the navigate itself. Instead, this is a mechanism for event listeners to communicate with one another. There are probably other such mechanisms we could consider to meet the same purpose.
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 blockUntil
) and respondWith
, however:
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 blockUntil
) is resolved.
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});
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?
It kind of depends on what you mean.
We cannot delay the actual navigation. location.href
and appHistory.current
and the rest have to update synchronously. So the navigate
event needs to happen, and complete, synchronously; there's no way to hold it.
You can always stop event propagation to other handlers by using event.stopImmediatePropagation()
.
Probably the best way is to be sure to respond to the event, and then only notify child routers or components later, e.g. after singleSpa.triggerAppChange()
. The idea would be these components don't listen to appHistory
's navigate
event---in general, the design has always been that there'd only be one listener for that---but they'd listen to some single-spa-specific event.
The latter is how we've mostly designed this API. But saying
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.
indicates it probably won't work for your case.
We decided on the API "respondWith" but I'm having doubts.
I've never had a strong attachment to the name respondWith(). Indeed you don't supply an actual Response
object like in cross-document service worker navs that go to a new HTTP response; instead you manipulate your page's state in order to update it for the new "response". To me these are analogous enough, and the name is reasonable enough, but I'm open to other ideas.
I think we haven't fully considered the case of multiple navigate event listeners.
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:
But what happens if an earlier event listener calls respondWith?
As currently specced, calling respondWith() or preventDefault() means that any further calls to respondWith() will fail with an "InvalidStateError" DOMException.
Should the later event listeners not get the navigate event at all?
We could spec that, and it might be a good idea.
Should they be called synchronously, or after the respondWith resolves?
Multiple event listeners are always called synchronously; this is a feature of all EventTarget
s.
And I think unlike the fetch API, it's reasonable to assume that someone could accidentally load two sets of code that both register navigate handlers.
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.
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 withhold but I'm wondering whether we should use the term block because it clearly shows that something is being delayed, and has precedence in other systems (like Node).
Can you explain what this would do? Recall that we can't actually delay the navigation; we need to update location.href
synchronously. We also can't asynchronously delay other event handlers; firing an event means running all of its handlers, synchronously, until one of them calls stopImmediatePropagation()
or you're done.
So, would the semantics here be something like preventDefault()
plus re-issuing the navigation after a delay, which would fire the navigate
event again?
Instead, this is a mechanism for event listeners to communicate with one another. There are probably other such mechanisms we could consider to meet the same purpose.
My general suggestion is that frameworks should try to move this down a level, and have only one navigate
listener which then uses framework-specific mechanisms to communicate among "sub-listeners" which themselves don't directly listen to navigate
. Event listeners on the web platform have very defined semantics and not a lot of room for introducing new ones.
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?
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.
Service workers has struggled with this for years, and unfortunately not gotten anywhere. Their issue is https://github.com/w3c/ServiceWorker/issues/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.
The latter is how we've mostly designed this API. But saying
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.
indicates it probably won't work for your case.
Yes, agreed.
To answer your specific questions:
But what happens if an earlier event listener calls respondWith?
As currently specced, calling respondWith() or preventDefault() means that any further calls to respondWith() will fail with an "InvalidStateError" DOMException.
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 respondWith()
regardless of what happened in previous handlers (barring preventDefault()
or something). I'll have to update the polyfill I'm working on to behave in this manner.
This behavior actually seems like it could open the door to some brittleness - router libraries could reasonable assume that you can just call respondWith()
all the time (since in most cases they're the only one listening to navigate
) and not knowing that some other 3rd party code or userland code could make calling that function throw an error.
Should the later event listeners not get the navigate event at all?
We could spec that, and it might be a good idea.
Yeah, that can help with the brittleness I mentioned above. Or another option would be to set evt.canRespond = false
on later handlers so that they know they can't call respondWith()
?
And I think unlike the fetch API, it's reasonable to assume that someone could accidentally load two sets of code that both register navigate handlers.
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.
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 single-spa
is an example of where multiple handlers are explicitly set up, without anything having "gone wrong" 🙂. Perhaps, with some investigation, there could be other "good" examples of multiple handlers found?
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 withhold but I'm wondering whether we should use the term block because it clearly shows that something is being delayed, and has precedence in other systems (like Node).
Can you explain what this would do? Recall that we can't actually delay the navigation; we need to update
location.href
synchronously. We also can't asynchronously delay other event handlers; firing an event means running all of its handlers, synchronously, until one of them callsstopImmediatePropagation()
or you're done.So, would the semantics here be something like
preventDefault()
plus re-issuing the navigation after a delay, which would fire thenavigate
event again?
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.
Instead, this is a mechanism for event listeners to communicate with one another. There are probably other such mechanisms we could consider to meet the same purpose.
My general suggestion is that frameworks should try to move this down a level, and have only one
navigate
listener which then uses framework-specific mechanisms to communicate among "sub-listeners" which themselves don't directly listen tonavigate
. Event listeners on the web platform have very defined semantics and not a lot of room for introducing new ones.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?
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!
To answer your specific questions:
But what happens if an earlier event listener calls respondWith?
As currently specced, calling respondWith() or preventDefault() means that any further calls to respondWith() will fail with an "InvalidStateError" DOMException.
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 blockUntil/withhold
proposal, I think I'll create a new issue to just discuss that part
[edit] reading your response below now - we posted nearly at the same time :D
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.
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.
Multiple non-overlapping navigate
handlers work relatively fine, as long as they can synchronously determine whether or not they want to performing "mutating actions", i.e. respondWith()
and preventDefault()
.
Example: if one navigate
handler handles all URLs starting with /profile
, and another handles all non-/profile
URLs, then they can just have if
statements at the top of their event listener, and do nothing for the cases outside their scope.
The problem comes about when you can only determine asynchronously which URLs you want to handle. This appears to be the single-spa case.
The asynchronous case can be handled today by monkeypatching. Is that sufficient? I think it depends on how common it is. If it's very special libraries like single-spa, whose goal is to allow multiple unmodified frameworks to run in the same web app, then maybe it's OK. If it's a more general thing that many different frameworks would like to do, then perhaps we should reconsider. But my instinct is that most frameworks do not expect (and should not expect) their users to use appHistory directly; instead they will install a single framework-wide navigate
handler, and use framework-specific mechanisms to delegate work to various app components.
If we did consider the asynchronous case to be highly important, then I'd like to reconsider the decision in #46 / #19 to make all same-document navigations synchronous. In other words, if delaying action until an async determination is made is a key goal of app history, then we should just delay the whole navigation to be asynchronous, instead of doing something kludgy like telling people to synchronously intercept or cancel the navigation but later reissue it (like discussed in https://github.com/single-spa/single-spa/discussions/762#discussioncomment-576997).
There are several smaller surface improvements we could make to help multiple navigate
handlers work better together. The current design, of respondWith()
preventing further respondWith()
calls with no signal, is probably not optimal. Some ideas:
Update event.canRespond
in response to respondWith()
calls. Right now it's static and reflects the interceptability of the event according to The Table; we could make it dynamic instead. Then, future handlers could get a more accurate sense of whether respondWith()
works.
Automatically perform event.stopImmediatePropagation()
whenever respondWith()
is called. Then, future handlers wouldn't see the navigate
event at all.
Allow multiple calls to event.respondWith()
. Only the first has the interesting impact of converting the cross-document navigation into a same-document navigation. The others add their promises to a list, which gets Promise.all()
ed to delay the navigatesuccess
and navigateerror
events.
Change the updating of location.href
and appHistory.current
to synchronously happen after all handlers have finished running (if respondWith()
was called by any of them), instead of happening synchronously as part of respondWith()
. This helps avoid the problem where future handlers see appHistory.current === appHistory.destination
. Edit: this was done in https://github.com/WICG/app-history/pull/103.
I largely agree with Domenic's assessment here.
I think it depends on how common it is. If it's very special libraries like single-spa, whose goal is to allow multiple unmodified frameworks to run in the same web app, then maybe it's OK. If it's a more general thing that many different frameworks would like to do, then perhaps we should reconsider. But my instinct is that most frameworks do not expect (and should not expect) their users to use appHistory directly; instead they will install a single framework-wide navigate handler, and use framework-specific mechanisms to delegate work to various app components.
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.
In other words, if delaying action until an async determination is made is a key goal of app history, then we should just delay the whole navigation to be asynchronous, instead of doing something kludgy like telling people to synchronously intercept or cancel the navigation but later reissue it
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 event.respondWith
seems reasonable to me, but it begs the question whether you'd want to add the ability to get the current promise, so that you could .then()
it, rather than just add to the Promise.all
Change the updating of location.href and appHistory.current to synchronously happen after all handlers have finished running (if respondWith() was called by any of them), instead of happening synchronously as part of respondWith(). This helps avoid the problem where future handlers see appHistory.current === appHistory.destination.
Oh, this seems like something we'd want to do, regardless of the outcome here.
The problem comes about when you can only determine asynchronously which URLs you want to handle. This appears to be the single-spa case.
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 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.
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 withhold()
/blockUntil()
proposal here - while nice for a library I maintain - is probably quite a bit more niche and should be considered separately.
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 😊