-
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
Will the current transitionWhile() design of updating the URL/history entry immediately meet web developer needs? #66
Comments
I will try to reach out to some of my framework friend and see if they will leave any feedback |
Thank you @kenchris for sharing this. @mhevery @IgorMinar @alxhub @mgechev @@pkozlowski-opensource how is this going to affect Angular? |
Also adding my friend @posva from the Vue.js team. Any feedback on this, Eduardo? |
This is true. Being able to get the updated
From Vue Router perspective, all navigations are asynchronous, meaning calling |
I don't have much additional to add for Angular beyond what @posva mentioned for Vue. Angular's router operates in much the same manner. We do provide the ability to update the URL eagerly via the urlUpdateStrategy. The default is We also have the same issue with the I'm in the process of going through the proposal and evaluating how we would integrate it into the Angular router. |
I’m in that camp, but it’s not about the URL bar for me, it’s about ensuring APIs like Since the URL bar’s contents aren’t accessible to JS, it’s the one aspect of this which doesn’t matter to me — whether it updates optimistically wouldn’t impact whether there is an observable misaligned transitional state. (It seems I want the exact opposite of what’s been described, if I understand right? — cause for me it’s “not delaying updates [to location.href, etc] is problematic.” Immediately-update-code-observable-location behavior might prevent us from making meaningful use of respondWith, though other aspects of AppHistory would remain useful to us regardless.)
While I can’t answer for the folks above, brief unscientific testing suggests that in our case it falls between 0 and 150ms (w/
|
Sure, they are explained here and they usually last between 16ms or a few seconds. They can be deferred indefinitely but they shouldn't. |
Currently playing with the Chromium impl, the required pattern to achieve the behavior we currently have (location updates after successful state transition) is roughly:
The built-in respondWith needs to be ignored in this model - the only "response" action is to preventDefault - but the end result is a similar API surface. It does seem like an improvement because it involves less code than current History-based solutions, but the misalignment is also very stark. |
Very interesting! The fact that you are able to mostly emulate the delayed-URL-update behavior based on the current primitives is a good sign. It feels similar to #124 in that maybe we can add it to the API on top of what exists, so that anyone who wants to achieve a similar experience can just use app history directly instead of needing to get all the edge cases right in a way similar to what you describe. |
Edit: This was a previously question I asked due to mistaking a behavior change related to location updates for one which would implement the behavior I’m seeking. But I got ahead of myself and can answer my own question about it: the bit I was clearly not paying attention to was that it said “after all handlers have finished running,” so no, the behavior did not change in the way I thought. Leaving the comment intact below to avoid confusion if people got emails. Original comment/question@domenic I just saw #94 (comment):
Is it correct to interpret this as saying Previous comments had given me the impression that location/current updating at the start rather than the end of a transition was a settled question. Our last two comments here are from a month later than the comment I quoted, too — so I’m not sure if maybe I’m misinterpreting what I read there. If this behavior is set to change, the cancel-initially-but-repeat-the-navigation-again-later dance I described in my prior comment won’t be needed as far as I can tell. Since I think this was the only challenging disconnect between AppHistory’s behavior and the routing behavior we currently implement and want to preserve, if location update deferral is real now, it likely means we’ll be able to use the appHistory API “bare”! I am dizzy imagining the possible thundering satisfication of just outright deleting ten thousand pounds of terrifying tower-of-hacks-and-monkey-patches History mediation code without leaving one trace ... gonna go lie down whew |
From @jakearchibald in #232
We've known for some time that the content will not match the URL. This thread (as well as various offline discussions) have various framework and router authors indicating that is OK-ish, except for @bathos who is working around it. The novel information for me here is Jake's consideration for relative URLs. It's not clear to me exactly what goes wrong here; surely for new content, you want to fetch things relative to the new URL? But I can imagine maybe that's not always what's assumed. Anyway, toward solutions: the big issue here is that any delay of the URL needs to be opt-in because it breaks previous platform invariants. Consider this: navigation.addEventListener("navigate", e => {
e.transitionWhile(delay(1000), { delayURLUpdate: true });
});
console.log(location.href); // "1"
location.hash = "#foo";
console.log(location.href); // Still "1"!!! Similarly for all other same-document navigation APIs, like As long as we have such an opt-in, we can probably make this work. The page just has to be prepared for same-document navigation APIs to have delayed effects. |
fwiw, I think it'd be fine if fragment changes are guaranteed to be synchronous. Only navigations that would be cross-document can have their URL update delayed, matching MPA behaviour. |
Here are a couple of demos that might help: https://deploy-preview-13--http203-playlist.netlify.app/ - requires Canary and This was supposed to be my "works fine" demo because it doesn't use relative links. However, I noticed that if I click one thumbnail, then click another within the two seconds, the transition isn't quite right. This is because my code is like: navigation.addEventListener('navigate', (event) => {
event.transitionWhile(
createPageTransition({
from: navigation.currentEntry.url,
to: event.destination.url,
})
);
}); Because the URL commits before it's ready, on the second click the https://deploy-preview-14--http203-playlist.netlify.app/ - here's an example with relative URLs. If you click one thumbnail then another, you end up on a broken URL. Even if you click the same thumbnail twice, which some users will do accidentally. My gut feeling is that relative URLs on links is uncommon these days, but there are other types of relative URL that will catch folks out here. Eg, fetching |
I wonder if frameworks have adopted the model of:
…because of limitations in the history API, or whether they think this is the right model in general. Thoughts @posva @atscott? It just seems like a fragile ordering due to things like relative URLs, and other logic that wants to do something meaningful with the 'current' URL, assuming it relates to the content somehow. It's also a different order to regular navigations, where the URL updates only once content is ready for the new view. |
sveltejs/kit#4070 - Svelte switched to a model where the URL is only changed when the navigation is going to succeed, as in the new content is ready to display. From the issue:
This isn't a problem with the navigation API, as it activates the browser loading spinner. |
@jakearchibald FWIW, we don’t use relative pathnames, but we use relative query-only links in all our search/filter panels. This is where URL-first hit us. |
Oh yeah, I imagine that kind of thing is way more common |
Echoing Jake, updating the URL before the navigation completes is very fragile. It's true that many prominent SPAs (including the very site on which we're having this discussion) do update the URL eagerly — and I suspect it is because there's otherwise no immediate feedback that the navigation intent was acknowledged — but we moved away from this approach in SvelteKit because it's buggy. Anything that involves a relative link during that limbo period is likely to break. Here's a simple demo — from the Browser UX-wise, I think there's a case for updating the URL bar immediately — it provides an even clearer signal that activity is happening, and makes it easier to refresh if the site is slow to respond, and so on. But I think it's essential that |
Thanks all for the great discussion. My current take is that we need to offer both models (early-update and late-update/update-at-will), and this discussion ups the priority of offering that second model significantly. Read on for more philosophical musings... Cross-document (MPA) navigations have essentially four key moments:
The raw tools provided by
But you can also emulate other flows, e.g. many people on Twitter mention how they prefer to consolidate Start + Commit. Or, you can do things MPAs can't accomplish at all, such as replacing "Switch" with "Show Skeleton UI", and then doing Start + Commit + Show Skeleton UI all together at once. The navigation API currently guides same-document (SPA) navigations toward the following moments:
We've found this to be pretty popular. Especially given how developers are keen to avoid double-side effect navigations (e.g. double submit), or to provide instant feedback by e.g. loading a skeleton UI of the next screen instead of waiting for network headers. We've even found Angular transitioning away from a model that allows late-Commit, into one that only allows Start+Commit coupled together. But as some folks on this thread are pointing out, it's not universal to couple those two. And coupling comes with its own problems; it's really unclear what the best point at which to update So we should work on some way of allowing Commit to be separated from Start, for SPAs and frameworks which want that experience. |
So, https://twitter.com/aerotwist/status/1529138938560008194 reminded me that traversals are very different here than push/replace/reload navigations. For implementation reasons, it is extremely difficult to allow delayed Commit for them. (This is in fact a big reason why Angular is transitioning to not allowing delayed Commit.) Introducing an asymmetry between traversals and others seems unfortunate, so I'm now less optimistic about solving this soon... We've discussed something similar in #32, about preventing traversal Commit. Preventing and delaying are essentially equivalent, because delaying means preventing and then re-starting. Some of the more technical discussions in #207 and #178. The best current plan is allowing preventing traversal Commit for top-level frames only, and only for navigations initiated by user activation. How would you all developers/framework authors feel if we could only delay Commit for push/replace/reload, but traversals always had Start+Commit coupled? |
Here's the historical context for the eager vs deferred url updates in Angular: angular/angular#24616 In defense of the initial design deferring the URL update:
The motivation to also support URL eager updates:
Angular supports both eager and deferred URL updates and will likely need to continue to do that. Having the support for both in the navigation API would make adopting it easier so we won't have to work as hard to get both behaviors. FWIW, I've never felt totally comfortable with the deferred strategy because it's not possible to maintain this behavior for all cases. What I mean is that even when using the deferred URL update strategy, the URL update is still "eager" when users navigate with forward/back buttons or the URL bar directly. On the issue with relative links: we don't exactly have this issue in Angular because we expect users to only trigger navigations using the router APIs (for better or for worse). Links in the application are expected to use the |
Ah, that's a real sadness. They behave with delayed commit in regular navigations. I agree that there's little point adding delayed commit for new navigations if we can't have them for traversals too. |
That's how it works at present for those of us deferring I noticed that traversals do involve a delayed commit when bfcache is disabled (e.g. there's an Small point of clarification: to use the terminology in #66 (comment), SvelteKit navigations (other than traversals) are Start then Commit+Switch+Finish. I think that's probably the case for most SPA-ish frameworks (i.e. there are two 'moments', and it's either that or Start+Commit then Switch+Finish); IIUC even frameworks that do Turbolinks-style replacements generally do a single
I've spent a lot less time thinking about the nuances of the Navigation API than other people in this thread, but it does feel like something as fundamental as navigation should have one correct approach. The fact that SPA frameworks currently have different opinions about this is entirely because of the History API's terribleness, and I think it would be a shame if that prevented the Navigation API from being a clean break with the past.
We're getting into edge case territory, but it's not just relative links — they're just the most obvious way in which things can break (and it's not always practical to use a framework-provided component, for example when the links are inside HTML delivered from a CMS). Constructing |
Sort of. How it actually works is that traversals are locked in and uncancelable roughly from the moment they start. So from the browser's point of view, they are "committed". (But, I agree this is not precisely the same as the "Commit" step I mentioned above... I can dig into this in nauseating detail if we need to.) In more detail, there is an inter-process communication sent immediately upon any traversal-start, which gets a lot of machinery irreversably moving, including (in the non-bfcache case) the network fetch. So the technical problems come with inventing a new code path which delays this usual flow. There is some precedent, in that beforeunload can prevent traversals. But then you start getting into less-technical issues around preventing back-trapping abuse... Anyway, to be clear, I'm saying "extremely difficult" and "less optimistic about solving this soon", not "impossible".
It seems possible in theory to decouple them, subject to security/UI team's review. However I'm not sure how many problems it solves. The technical design constraints are not really related to the UI. On the implementation-difficulty side, they're as explained above; on the web developer expectations side, I think they're about what a given app/framework's expectations are for
I agree most SPA-ish frameworks tend to prefer a two-moment model. However I most commonly experience a split of Start+Commit+Switch, then Finish. E.g. start on a fresh tab with https://twitter.com/home , then click the "Explore" button.
I'm really torn on this!! And have been since March 9, 2021, when this thread was opened :). My instincts are very similar to what you're saying. However I just have a hard time picking a model and saying everyone should use the same one. Especially given all the difficulties around traversals, and how I thought we had a good model but now Jake and you are coming in with solid arguments for a different model. I think the constraints are the following:
So this leads to the navigation API providing either The fact that I am torn and people have good arguments for both places to put Commit, makes me want to build
I am really curious to hear more about how people handle this. Jake and I were unable to find any non-demo sites that were broken in the wild by this issue. This suggests developers are either working around it painfully, or their frameworks are handling it for them. E.g., one way a framework could handle it for you is by preventing any code related to the "old" page from running, after Commit time. Is that a strategy anyone has seen? What does Angular do, @atscott? |
I don't know if I totally follow the whole problem here but I think the missing piece from @Rich-Harris is "it's not always practical to use a framework-provided component, for example when the links are inside HTML delivered from a CMS". To me, this means we're working outside the confines of the framework so this isn't really anything Angular would or could control. For things inside the application, we do expect any URLs to be constructed from Router APIs. This means that any relative links created from within the application would only update when the Angular Router state updates. That said, regardless of "eager" or "deferred" browser URL/location updates, this internal state update in the Router is always deferred until we are pretty certain the activation will happen: https://github.com/angular/angular/blob/8629f2d0af0bcd36f474a4758a01a61d48ac69f1/packages/router/src/router.ts#L917-L927 |
Yeah, this looks like the most promising of the proposals so far. I'll play with it and see if I can any issues. |
|
It should throw, similar to
Again, throw, similar to
Yes, once the promise settles. Also similar to |
Minor expression of a preference for this sort of neatness. When the/a common case for a callback API ends up requiring per-call closures for the sake of capturing effective operands / “subject objects” lexically, I see a stack of (More generally I think newcomers and oldcomers alike benefit when APIs that abstract control flow “usher” you through the flow as clearly as possible, e.g. “the intercept handler is what receives the capability to commit, so I know I can’t do it before that”.) |
@domenic want are your thoughts on this? Should navigateEvent.intercept({
commit: "manual",
async handler() {
// The old URL is still active.
const dataPromise = fetchPageData();
const shell = await fetchPageShell();
navigateEvent.commit();
// Now the new URL is active.
updateDOM(shell);
shell.populate(await dataPromise);
},
}); vs navigateEvent.intercept({
commit: "manual",
async handler(interceptController) {
// The old URL is still active.
const dataPromise = fetchPageData();
const shell = await fetchPageShell();
interceptController.commit();
// Now the new URL is active.
updateDOM(shell);
shell.populate(await dataPromise);
},
}); The argument is, The counterargument is… ugh a new object just for one method. |
Yeah, I think we have enough precedent for putting things on the NavigateEvent object, with options controlling them being passed to intercept(). I like putting it there. |
I'd lean toward following the precedent of putting it on NavigateEvent, too. There's one extra corner case to consider with that approach, though:
In immediate commit mode, the commit will occur when the navigate event completes. In this example, manual commit mode can be used to commit the navigation slightly before immediate commit mode would allow. I think we might want to prevent |
One other question that came up in conversation with @domenic: how useful is Background: If we introduce If deferred commit for traversals is critical, though, then I would need to go back and generally make traversal navigations cancelable, which is a pretty big implementation hurdle. It can be done, but it makes the effort:reward ratio a lot lower for this feature. |
Particularly interested in feedback from @posva, @Rich-Harris, and @atscott on @natechapin's question above! |
That seems fine. From the developer's point of view, |
I hope I understood everything well, there was quite a lot to catch up and had to go back and read other parts of the proposal as well 😅. I tried to keep this concise:
IMO yes, this is still useful because in SPA routing we need a way to defer the URL update. It's far from ideal though but it's the same problem as #32. The Navigation API wants to provide a new API that allows for two models of navigations (eager and deferred) but the problem is we are still forced to use the eager model for UI based navigations. In Vue router (and I think others) we have:
I think that for SPA routing, they are critical, pretty much like #32 because it enables both models in all scenarios. Another possibility would make eager URL updates the new default but I don't think that's a good idea because an SPA navigation can still be canceled and that would mean the URL would change twice: once to start the navigation and again when it's canceled. |
I implemented a proof of concept After using |
If Seems like it just goes uncaught: navigation.addEventListener("navigate", event => {
event.intercept();
throw new Error("Sync error");
})
navigation.addEventListener("navigateerror", event => { console.error({ event }) })
await navigation.navigate(`/test/${Math.random()}`).finished vs navigation.addEventListener("navigate", event => event.intercept(Promise.reject(new Error("Async error"))))
navigation.addEventListener("navigateerror", event => { console.error({ event }) })
await navigation.navigate(`/test/${Math.random()}`).finished Taking the name navigation.addEventListener("navigate", event => {
event.intercept();
if (someCondition) {
event.reportError(new Error("Sync error"));
} else {
event.commit();
}
})
navigation.addEventListener("navigateerror", event => { console.error({ event }) })
await navigation.navigate(`/test/${Math.random()}`).finished
|
…sync commit in the Navigation API * Move the SawURLChange() call for a soft navigation intercept()ed by the NavigateEvent to NavigateEvent::DoCommit. This will eventually need to handle both immeidate commits and delayed async commits. * Rename SoftNavigationHeuristics::SetBackForwardNavigationURL to SetAsyncSoftNavigationURL. Currently, it's only used for back/forward navigations because those are the only navigations that commit asynchronously right now. That will change when NavigateEvent.intercept() gets support for deferred commits (WICG/navigation-api#66), so give it a more generic name that describes the cases where it's needed. Change-Id: I16bf7c732c7c977f928bee3cc0bf8d945aabd88b Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/4288932 Reviewed-by: Yoav Weiss <[email protected]> Commit-Queue: Nate Chapin <[email protected]> Cr-Commit-Position: refs/heads/main@{#1110422}
Hey all!! @natechapin is working on a prototype of this in Chromium, and the explainer is available for review in #259 . You might enjoy taking a look! |
Hey everyone, @domenic and I have been bikeshedding names in the code review protoype, and I wanted to get some additional feedback. Based on @jakearchibald's suggestions earlier in this thread, I had used Does anyone have thoughts on the clearest name? |
+1 for |
Thanks for the feedback! One more question: If the commit is deferred, and the Our proposal is to forbid
Note that this is a relatively rare situation, because it requires the navigation to be cancellable but also interceptable, which is only the case for uncancellable same-document traversals. That means you're not going to accidentally go cross-document if we choose the first option. Thoughts? |
I think this is probably more reasonable than throw, right? You might still want to intercept to get the other benefits of the transition (loading spinner, scroll restoration, etc.) |
I think the main concern is whether unexpectedly having an |
I guess I have a slight preference for throwing an error, but I think it's also reasonable to intercept without throwing an error. |
Ah, sorry I forgot that this is actually a property on the intercept options. I think it probably makes sense to throw in that case. |
As such, call the feature "deferred commit" instead of "manual commit". See discussion in #66 (comment).
For anyone watching this thread, note that the explainer is updated to describe this new ability, and this new ability is available in Chromium >= 114.0.5696.0 behind the experimental web platform features flag. This issue remains open at least until the spec is updated, which hasn't happened yet. Since this is a tricky area, we're looking for web developer testing and validation that what we've built here is good and usable. Please play with it behind a flag and let us know if it's something we should ship! |
I've been using |
I've got a router library that has gesture navigation and delaying the commit is super useful since you can technically "cancel" the gesture. It's a lot more straightforward to simply reject the promise returned in the handler passed to intercept rather than having to patch the history (traverse back to the previous entry) after the gesture to rollback the navigation. Note that for Safari on iOS this is much closer to the default UA behaviour when gesture navigating, so this might be a pattern that gets replicated a lot. |
As discussed previously in #19 (and resolved in #46), intercepting a navigation using the
navigate
event and converting it into a same-document navigation will synchronously updatelocation.href
,navigation.currentEntry
, and so on. I think this is on solid ground per those threads; delaying updates is problematic. (EDIT: further discussion, starting around #66 (comment), reveals this is not quite settled.)However, I wonder about the user experience of when the browser URL bar should update. The most natural thing for implementations will be to have the URL bar match
location.href
, and update immediately. But is this the experience web developers want to give their users?I worry that perhaps some single-page apps today update the URL bar (using
history.pushState()
) later in the single-page-navigation lifecycle, and that they think this is important. Such apps might not want to use the app history API, if doing so changed the user experience to update the URL bar sooner.The explainer gestures at this with
and this is an issue to gather discussion and data on that question. It would be especially helpful if there were web application or framework authors which change the URL in a delayed fashion, and could explain their reasoning.
A counterpoint toward allowing this flexibility is that maybe standardizing on the experience of what SPA navs feel like would be better for users.
The text was updated successfully, but these errors were encountered: