-
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
Worries about making all navigations async #19
Comments
This needs more thought for sure. I do think that we can't change the behavior of updating the URL synchronously - that's baked in, code depends on it, and there's no way we can change it. I think we should think about divorcing the update of a URL with the end of a navigation - there are certainly instances of where you'd want to front-load the URL update even though the page is still rendering in response to the change (and you'd want to defer other navigations while you're rendering). Doing so would allow us to continue to have navigations be asynchronous, but would allow us to continue having the URL update at the start of a location.hash navigation, e.g. I think the worst of all worlds would be to have navigations be both synchronous and asynchronous, depending. I think everything must be either all synchronous or all asynchronous, and any middle-ground is dangerous. |
From a spec point of view, I'm already going to handle sync updates to hash & |
Here's another possibility, which is perhaps attractive in its simplicity: only make navigations async if there's a That is: location.hash = "foo";
console.log(location.hash); // logs "foo"
appHistory.addEventListener("navigate", e => {
e.respondWith(Promise.resolve());
});
location.hash = "bar";
console.log(location.hash); // logs "foo" still---navigation is async
Promise.resolve().then(() => {
console.log(location.hash); // now logs "bar"
}); This... seems pretty nice? I'm curious how it fits with
though. |
We have to handle the case of the URL updating synchronously. What remains I think more open for debate is how and whether we support synchronous navigations. When I approach this problem I like to think of What should happen when a user does that? Whatever happens when a user does that, that's probably what should happen when someone in code writes So I wouldn't expect to change the hash of the URL and then my URL doesn't display that change because there's some JavaScript with a e.respondWith(Promise.resolve()) callsite. Maybe we should require that the URL is updated synchronously for any and all navigations, and that if you use e.respondWith() it delays the resolution of the navigation (and would block further navigations, unless the current one is cancelled) but the update to the URL still happens synchronously. Then, we allow people to choose a different URL once the navigation is finished, basically like a history.pushState() followed by a quick history.replaceState() when a render finishes. |
Hmm, I don't quite understand the distinction you're making there?
Well, we have choices here, because the time taken by Perhaps a more salient question is, what should happen if a
This sounds reasonable to me. I don't think it's the only path, but perhaps it's the most consistent, and it seems fairly promising. Let me try to write up in more detail what it would look like... |
as a more concrete example, I could potentially see someone wanting to make an API call during |
Could hash updates remain sync, but the |
Let's analyze scenarios where the promise takes a long time to settle, in this do-most-things-up-front paradigm. Here's a concrete proposal for how it would look: Success caseappHistory.addEventListener("navigate", e => {
e.respondWith(new Promise(r => setTimeout(r, 10_000)));
});
await appHistory.push({ url: "/foo" });
Failure caseappHistory.addEventListener("navigate", e => {
e.respondWith(new Promise((r, reject) => setTimeout(() => reject(new Error("bad")), 10_000)));
});
await appHistory.push({ url: "/foo" });
Interruption caseThis involves a bit more code to show how you are supposed to properly handle abort signals. It also uses appHistory.addEventListener("navigate", e => {
e.respondWith((async () => {
await new Promise(r => setTimeout(r, 10_000));
// Since there's no setTimeout-that-takes-an-AbortSignal we check manually here.
// A manual check isn't needed if you're doing something like fetch(..., { signal: e.signal }).
if (!e.signal.aborted) {
document.body.innerHTML = `navigated to ${e.destination.url}`;
}
})());
});
const promise = appHistory.push({ url: "/foo" });
setTimeout(() => {
location.hash = "bar";
}, 1_000);
Questions
|
Bike shed:
Can you add when the navigate handler is called? It's not clear to me from these examples. I would expect it between navigateto and currentchange? Other than that, this feels right. For the interrupt case, why does the push() abort the previous push()? Should it queue instead? |
What's transitioning?
Good question... I think it'd be weird to put it in that specific spot, because IMO we should minimize the code that runs between Other than that, we have a lot of flexibility. It could even happen before
I'm currently leaning toward the last option. WDYT?
Also a good question. We need to figure out our queuing plan in general. But, what about a very similar example: location.hash = "foo";
setTimeout(() => {
location.hash = "bar";
}, 1_000); My intuition was that this should not queue; it should abort. So I was thinking |
Well, one page to another. I guess "navigating". I dunno.
We kind of have to do this if we want to allow for complete URL rejections or modifications right? Otherwise the URL bar would flash until the
I think whether it queues or preempts depends on the operation and API. I would expect location.hash (and URL bar updates, and history.pushState) to all preempt, and no way to make them queue (unless, I suppose, you cancelled in the |
Well, then I'm confused, I thought that's what inProgress is. What are the three states you see?
Nah, we have 16 milliseconds of asynchronicity allowed before any URL bar flashings occur.
Interesting, OK. That's definitely doable, and makes sense; if we don't expect complete symmetry between |
Oh, whoops, I'm just bike shedding the name for
I still think that having the navigate handler go first probably makes sense - if it cancels a navigation, you wouldn't expect ANY |
Ah, understood about the names, and agreed about going first. I'll update the above description to reflect that. Now, next up is to do the same analysis for cross-document navigations, where |
I started writing up a counterpart analysis to #19 (comment) for normally-cross-document navigations, like location.href = "/foo";
console.log(location.href); will log the old location. (And then the JS will quickly stop running as the navigation proceeds to unload the current document.) I quickly found that an in-depth analysis isn't really needed. We basically have two choices:
(1) feels much better to me. The intent of The only advantage of (2) is that it preserves the property where I'll start working on an explainer pull request that incorporates this. |
This includes ones generated by intercepting the navigate event with event.respondWith(). However, the promise passed to event.respondWith() is still useful for signaling the navigation's successful or unsuccessful completion. That is used to fuel browser UI, such as the loading spinner, as well as promises and events. And a rejected promise passed to event.respondWith() will roll back the navigation. Closes #19. Closes #44.
I've posted a draft at #46. Stepping back, I think we have three options:
An idea of what (3) could look like would be appHistory.addEventListener("navigate", e => {
// "Navigation finish" happens when this promise settles
e.respondWith((async () => {
const res = await fetch(e.destination.url);
// OK, we got this far; we're ready to commit:
e.commitNavigation();
// Now do more stuff in between "navigation commit" and "navigation finish"
const template = await res.text();
const html = await myCoolAsyncTemplateEngine(template, e.destination.state);
document.body.innerHTML = html;
})());
}); Overall I'm reasonably happy with (1), but I wanted to make sure we're considering all the options. |
I think I'm also happy with 1. 3 is kinda appealing just from a control perspective, but I'm not convinced it's a good tradeoff for complexity. |
This includes ones generated by intercepting the navigate event with event.respondWith(). However, the promise passed to event.respondWith() is still useful for signaling the navigation's successful or unsuccessful completion. That is used to fuel browser UI, such as the loading spinner, as well as promises and events. And a rejected promise passed to event.respondWith() will roll back the navigation. Closes #19. Closes #44. Still some discussion to be had on the failure case in #47.
For "2. All intercepted same-document navigations are async", I'd think that could cause issues if someone is updating pieces of their code at a time. If some of their code relies of navigations being synchronous, and then they start using this new API (or they include a library that uses this new API), it could break their code that hasn't been updated. |
The proposal currently makes all navigations asynchronous. This is to support allowing them to be intercepted by
navigate
and replaced by SPA navs. However, the implications of this are a bit tricky.Note that today, at least in Firefox and Chrome,
outputs the new URL, with
#foo
at the end.history.pushState()
andhistory.replaceState()
are a non-interoperable mess, but at least some of the time they're also synchronous.aElement.click()
for a fragment navigation link is asynchronous in Firefox, synchronous in Chrome, hmm.But the upside is that if we stick with the current proposal that
location.href
only updates at the "end" of the navigation, making navigations async this would be a breaking change.There are a few potential mitigations I can think of, although none of them are great:
Allow
location.href
(and other things, likedocument.URL
) to update, but still treat the navigation process as async, potentially reverting the URL if the navigation gets canceled. This seems very messy and likely to lead to bugs and problems, both in application code and browser code.Only apply this new navigations-are-async behavior if an event listener is present for
appHistory
'snavigate
event. Adding event listeners should not have side effects, so we'd probably change the API shape to something likeappHistory.setNavigateHandler()
instead of using an event. This seems doable, although perhaps intrusive into browser code.Only make navigations async for cases where they are already async in at least one browser, such as:
appHistory.pushNewEntry()
aElement.click()
for non-fragment navigations)aElement.click()
for fragment navigations, on the theory that other browsers can align with Firefox?location.hash
orlocation.href
updates that only change the hashhistory.pushState()
(probably; pending further browser testing)I'm not sure what the right path is here. It might be a more radical re-think, beyond these options. I also need to process the issues @esprehn filed around how queued-up navigations are problematic as currently proposed (#10 #11 #13); it's possible the process of solving those might change how we approach this problem.
The text was updated successfully, but these errors were encountered: