-
Notifications
You must be signed in to change notification settings - Fork 25.2k
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
Perhaps Router Resolve shouldn't wait for Observable to Complete? #10556
Comments
I've actually been bashing my head against the wall for the last two days over this exact thing. I had no idea the observable had to complete in order for the navigation to complete. Retrospectively, I understand it where it came from—the promise based approach. Without live observables, it is proving to be difficult to merge the angular router with John Lindquist's course on building Redux-style Applications with Angular, RxJS, and ngrx/store. It's also pretty unexpected. For example, this guide on RXJS says “[c]ompletion should not be taken as "event at the end of the program" or something. It's an event which may happen or not.” Is the work-around something like |
Yes, although I wasn't thinking about this as a defect-inducing issue because we've moved past that, I know what you mean, and we did initially experience that confusion as well. Several times our team hit a multi-hour snag trying to track this same issue down. The first was me, the next two were the same team member hitting it twice, once after a refactor. Embarrassingly, we both still stared at it for a while the second time: As usual, locating a defect with no visible error can be time-consuming. What is the workaround you are looking for? Yes, observable.take(1), a.k.a. observable.first() is how we initially hacked the issue. Unless I misunderstand your question, hot vs. cold not directly related. However, observable.first() will throw away all the following data. That's only a problem if you care about the following data (which you probably do, since otherwise your observable would probably be "completed"). |
@johnchristopherjones : Side note, I didn't see your question earlier. If you added it in an edit, you may want to post it separately next time. |
This is good idea, but i see at least two problems:
|
I believe this was fixed #10412 |
Thank you for the find/reference, Dzmitry. I raised a specific issue in my "desired behavior":
Is there a pattern in-mind, on how disconnects are handled now, after #10412 ? |
@DzmitryShylovich After an errant discussion on #10412, it looks like although it is a related behavior, it has no effect on this issue. That PR is intended to change the behavior of guards (OnActivate), not Resolve. |
Router 4 should really support hot routes, that is views that are updated once the observable emits new data. The activated route should flow the data and if route change is needed it should happen after the observable has emitted the first value. The disconnect to the resolve observable should happen when the route is not activated anymore. |
I have this same problem. I used a Resolve with a Promise for an Observable to work around it. My service in this case caches the data. It would be nice if this was supported natively. @Injectable()
export class DocumentsResolve implements Resolve<Promise<Observable<IDocument[]>>> {
constructor(
private _documentService: DocumentService,
) {}
public resolve(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot): Promise<Observable<IDocument[]>> {
return new Promise((resolve, reject) => {
const documentObservable = this._documentService
.list();
documentObservable
.first()
.subscribe(() => resolve(documentObservable));
});
}
} @Component({
selector: 'app-document-library',
templateUrl: './document-library.component.html',
styleUrls: ['./document-library.component.css']
})
export class DocumentLibraryComponent implements OnInit, OnDestroy {
public documents: Observable<IDocument[]>;
constructor(
private _route: ActivatedRoute,
) {}
public ngOnInit(): void {
this.documents = this._route.snapshot.data['documents'];
}
} |
Here is an example of simplifying above with a re-usable base class. export abstract class HotResolve<T extends Observable<any>> implements Resolve<Promise<T>> {
public resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise<T> {
return new Promise((resolve, reject) => {
const observable = this.hotResolve(route, state);
observable.first().subscribe(() => resolve(observable));
}
}
public abstract hotResolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): T;
} @Injectable()
export class DocumentsResolve extends HotResolve<Observable<IDocument[]>> {
constructor(
private _documentService: DocumentService
) {
super();
}
public hotResolve(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot): Observable<IDocument[]> {
return this._documentService.list();
}
} |
A good example for using this feature is the own angularfire2 - Firestore that work with real-time data updates. I would like to continue to note the updates to the resolved route data. |
hey @greggbjensen , in your example (second version) what must DocumentService be? Is it calling the service that I'm failing to get past navigation or is it vice versa and I'm supposed to call and use DocumentResolve with my service, if so what's DocumentService, I don't get how to use your code. also : "ERROR in src/assets/services/hot.resolve.service.ts(8,18): error TS2339: Property 'first' does not exist on 'Subject' ." |
@greggbjensen : Actually I think it takes more boilerplate code than that. You might have it already, under the covers elsewhere, but without it, here's what probably happens:
Note the network request is made twice, and yet navigation still completes without data sequenced on the observable stream. That's the reason for the reference to "PublishReplay" in my original comment. Years later, we are still fighting this issue pretty severely. We've iterated through many solutions, all of which are far from perfect due to lack of support for this need at various points in router code. For example, another problem is "where is the best place to unsubscribe?", which is why I posed this question on S/O. Yet another problem is a maintainable pattern for defining resolvers for this. If you like, I'll try to dig up some of our current base classes, but as I mentioned, none of it is pretty (not for lack of trying). |
Here's the core code for our current implementation relative to this topic: export abstract class ViewModelFactory implements Resolve<ViewModel> {
constructor(private router: Router) { }
// could possibly implement a shareReplay handoff, instead of this publishReplay connection manager,
// by monitoring ActivationEnd, which holds the ActivatedRouteSnapshot to directly unsubscribe or disconnect
resolve(route: ActivatedRouteSnapshot, router: RouterStateSnapshot) {
let vm = RouteValue.useActiveProvider<ViewModelFactory>(route).value(this, (k) => route.paramMap.get(k));
this.uponLeaving(route, () => vm.disconnect());
return vm.connect();
}
private uponLeaving(route: ActivatedRouteSnapshot, action: () => void) {
// TODO: This doesn't account for query parameters due to inaccessible UrlTree ctors
let url = [""].concat(...route.pathFromRoot
.filter(r => r.url instanceof Array)
.map(r => r.url.map(s => s.path)))
.join('/');
this.router.events
.filter(e => (
e instanceof NavigationEnd ||
e instanceof NavigationCancel ||
e instanceof NavigationError
) && !(this.router.isActive(url, false)))
.first()
.subscribe(action);
}
}
export abstract class ViewModel {
subscription = new Subscription();
private hasConnected = false;
connect() {
if (this.hasConnected) {
return;
}
this.hasConnected = true;
let observables = Object.keys(this)
.filter(k => this[k] instanceof Observable)
.map(k => this[k] = (this[k] as Observable<any>).publishReplay(1));
observables.forEach(o => this.subscription.add(o.connect()));
return Observable.combineLatest(observables).first().map(_ => this);
}
disconnect() {
if (this.subscription) this.subscription.unsubscribe;
}
}
@Injectable()
export class AppSpecificViewModelFactory extends ViewModelFactory {
// this factory provides a receiver for injected services we require,
// without needing a distinct class for each one-line network call
constructor(private ds: DataService, router: Router) {
super(router);
}
metric(metricId: number) {
return new VM.MetricViewModel(metricId, this.ds);
}
metrics() {
return new VM.MetricsViewModel(this.ds);
}
}
export class MetricViewModel extends ViewModel {
// we can't resolve this from router without an intermediary,
// unless we distribute matching magic strings (in this case 'metricId') through multiple files
constructor(public id: number, private ds: DataService) { super(); }
metric = this.ds.metric.getMetric(this.id);
metricFeedback = this.ds.metricFeedback.getForMetric(this.id);
metricCriteria = this.ds.metricCriteria.getForMetric(this.id);
}
export routes = TransformViewModelRoutes(AppSpecificViewModelFactory, [{
path: ':metricId',
component: MetricComponent,
// this arrow expression breaks AoT, and necessitates a pre-compile step.
viewModel: (r, p) => r.metric(+p("metricId")),
// the following resolver is implied by the transform above
// resolve: AppSpecificViewModelFactory
}]); |
Note that the above implementation has some hooks to solve other router issues we've encountered as well, such as distributing magic string parameters, type-safe resolves, constructing an UrlTree outside of the router, type-safe parent route parameters, and others. We haven't resolved pattern issues surrounding AoT and related issues. So much to say, but honestly we're all kinda burned out on this issue. |
@jasonaden : p.s. I just added a demonstration of where this issue is causing us a lot of grief. It also shows a couple other related issue we are trying to solve. I'd love it if you would take a look and give a little guidance, if you see better ways to accomplish any of this. We've been fighting this for a long time and could use some author guidance. |
@kemsky : "2. we need to unsubscribe at some point and it gets tricky when components are reused by the router" - totally agree. that's why this is so hard. you can see publishReplay and shareReplay used to address this in my sample above, however it becomes harder because UrlTree constructors and other related tooling isn't exported for public use. capturing query parameters would address the component reuse scenario, but it might be more easily addressed by integrating other internal router tooling instead. |
@syndicatedshannon Understood. I definitely agree this is an issue. Right now we're preparing for v6 RC.0, which means getting a few critical fixes in plus some updates to support the new rxjs. That being said, shortly after RC.0 starts, I'll be diving in on this topic plus others with regards to the router and hopefully directly addressing how Observables are exposed/used within the router. I know it's been a while on this topic. Part of the problem is not having consistency across the framework in terms of how Observables are exposed and used. But this is something we're looking to standardize on, and it will likely start with the Router. |
@tatsujb and @syndicatedshannon, here is an example of the DocumentService. It holds onto the original Observable for the request that was made. It then uses fetch to get the list again and publish back through the same stream, when an update, create, or delete is made. Any component that calls // Cache for hot observable that allows stream to be cached and pushed.
export class SubjectFetch<T> {
private _subject: ReplaySubject<T>;
private _observable: Observable<T>;
private _fetch: () => Observable<T>;
constructor(fetch: () => Observable<T>) {
this._subject = new ReplaySubject<T>();
this._observable = this._subject.asObservable();
this._fetch = fetch;
}
public fetch(aggregate: boolean = true): void {
this._fetch()
.first() // Prevent the need to unsubscribe.
.subscribe(value => this._subject.next(value));
}
public get observable(): Observable<T> {
return this._observable;
}
}
@Injectable()
export class DocumentService {
private _documentsCache: SubjectFetch<IDocument[]>;
constructor(private _http: HttpClient) { }
public list(): Observable<IDocument[]> {
// Use cache if we have it.
if (!this._documentsCache) {
this._documentsCache = new SubjectFetch(() =>
this._http.get<IDocument[]>(`http://somedomain.com/api/documents`));
}
return this._documentsCache.observable;
}
public create(document: IDocument): Observable<IDocument> {
const result = this._http.post<IDocument>(
`http://somedomain.com/api/documents`, document);
// Fetch documents list again and publish back through hot observable.
result
.first() // Prevent the need to unsubscribe.
.subscribe(() => this._documentsCache.fetch());
return result;
}
} |
@greggbjensen : do your observables all end themselves? I don't see the subscription token saved anywhere? Sorry, I'm not really following how this works. It's not critical, as I think I get the general idea relative to this ticket. |
@syndicatedshannon As far as I know on services and injectables you don't need to unsub since they handle this themselves. Unsubbing is a pattern reserved for components. |
I still don't get why the router does not support hot routes, that is, routing is done for each emit. IF the route is already correct one, then only the route data is pushed to the already visible view. All current guards etc. can be called - if the new emit changes something, the guard must be run again to see if the user still has permission to view etc. And the disconnect, or unsubscribe would happen once the route ceases to be active. |
The model I describe in previous comment works the same for the completable / http / promise based stuff I think, so no compatibility or warnings would be needed either. It would only enable the framework to listen to the observable if there is additional data down the road and pass that to the view after guard checks. It would just work. If the observable is still hot (not completed) when the next route change happens, it would be unsubscribed from. This, of course, is the high level developer experience I would like to have not knowing all the gory details about the current implementation + all the supported corner cases.. |
Personally, I very much like that idea. It would make it possible to have "real-time updates" to the authentication status as as soon as the status changes, the guard would do its job even if the route could previously be activated. However, I think it might make sense to implement this as a different, new, guard (canRemainActive or something similar) as canActivate is more concerned with activating the route initially. Maybe file a separate feature request? |
Actually scratch the part about a separate guard. I think it'd be more beneficial to have it in CanActivate directly as it avoids some weird questions and solved this issue as well as you described. That said, I don't know the internals either and can imagine that this raises questions about the order of guards etc. |
This is already a quite dangerous scenario where there could be competing pending request (although old one will be cancelled) and it's not clear which one will be used for guard, in a specific application one may have knowledge of some requests being idempotent, but a library shouldn't make that conclusion.
Anything with a pending state (common when used in view), like: // will get { id: '1234' }
this.auth().pipe(
startWith({ id: null })
)
This is not about using last emit, but count the emission and report error when it happened more than once.
Again.
A good API always works in any of the type-compatible inputs, rather than having additional semantic requirements, that's why
It doesn't make sense unless guard support any of the emission (not first one nor last one).
If someone don't know how to convert |
There's no pending request danger (that's what switchMap guarantees) and the framework doesn't have to make any conclusion here. Many guards will look exactly like that:
I don't see any issue with such an implementation other than the user may just have to add a
OK, so your suggestion is to keep the behavior as-is, but just log a warning if there has been more than one emission? Yeah, that makes sense to me.
The same argument could be made for the issue itself here: if you don't understand the difference between emission and completion and that you need to complete your observable, don't use observables. It's a fair argument, but not a newbie-friendly one. :-) Angular beginners don't usually "choose" to use observables, the HttpClient more or less forces it on them. |
Maybe just as a recap, I think we have the following proposals on the table, right?
My 2c: I think #1 and #2 can both be helpful, and are non-breaking dev-only additions. #3–#5 are actual changes, of which I'd prefer #5, then #3, and very much dislike #4. :-) |
Thanks for the recap @Airblader . One vote for option 5. |
Consider the timing:
So that what being checked against is not the user when guard being invoked. In the application there might be no chance for
Many options are reasonable to me, except the Imagine an API with: function process(arr: number[]): void {
const value = arr[0]
register(value)
} Then I'd say it's definitely not intuitive, it would either:
Back to the issue here, |
Point taken, I see now what you mean with how that causes the framework to make a decision it shouldn't be making. |
Resolvers get their data from services. Services should not modify observables to accomodate views, that's the Component's job, downstream from services and resolvers. I vote for 3 for short term, and 5 long term. The reason that it is intuitive, is that the resolver does not process the data, it just make sure there is at least some data before loading the component UI. Once there is, its job is done, and it just has to pass the data observable down to the component. |
Stumbled upon this issue and vote for 2, then 3 But deep in heart I vote for 4, it's better resolver stick with Promise instead of Observable. |
Regarding '#4' on @Airblader 's summary above, it is related to but does not address this issue, which is primarily about Resolve. The statement would need to be rephrased to "remove observable support from guards and resolve". Guarding is an extra step, not about the readiness of data, even if they support Observables, but about tests on ready data. Personally, I've accepted that guards cannot 'fix-up' resolve because they are an 'if' question, not a 'when' question. I have tried to use them for such only because they seemed like an accessible development point. I can't say with certainty, why observables were chosen as the basis for resolve and yet they do not actually support data streaming, but it's likely the designer had in mind the ability to cancel network requests, which is obviously important. I appreciate that a promise appears to provide a more intuitive return type given the current operation, but only if we overlook cancellation. At a glance, as currently phrased, #4 is a non-starter. |
IMO, an important aspect missing from that same summary is providing support for unsubscribing. For our projects, it may even be the most essential aspect, although it's hard to be certain since we've become accustomed to certain behaviors since this issue report 3 years ago. As many have suggested (on this thread and others), we can convert the Overall, I feel like, after RoutableComponents had such a rocky start, no one on the Angular team has been eager to take on these issues, which probably present not only design challenges but have historical baggage as well. Personally, if someone wants to as simply as possibly patch this issue for me and me only, I'd be satisfied with a symmetric resolve capability: knowledge of when a route enters and exits a particular requirement. |
I've hit a bump in the road with this so far because some of my data comes from a websocket connection. What I thought I'd do is use a guard for authentication and authorization, followed by a resolver for this data, but as this thread confirms, nothing happens. How feasible is #5? Right now, my biggest issue is on manual URL changes which triggers an asymmetrical reloading of data across my components. Does anyone have a solution for this? |
The basic premise of resolvers is to block component instantiation until the items have been resolved. If there was no need to have the data before component instantiation, the loading logic should be moved to the component instead. For the case where new data needs to be resolved when something changes, this is somewhat available through the runGuardsAndResolvers option, where the resolver data could be updated when parameters change. Other use-cases may generally be covered by #42953 if the router were more extensible and allowed for more configuration as to how guards and resolvers are run. Closing in favor of #42953 |
Complete not equal resolve
…On Fri, Jul 23, 2021, 5:20 PM Andrew Scott ***@***.***> wrote:
The basic premise of resolvers is to block component instantiation until
the items have been resolved. If there was no need to have the data before
component instantiation, the loading logic should be moved to the component
instead.
For the case where new data needs to be resolved when something changes,
this is somewhat available through the runGuardsAndResolvers
<https://angular.io/api/router/Route#runGuardsAndResolvers> option, where
the resolver data could be updated when parameters change.
Other use-cases may generally be covered by #42953
<#42953> if the router were more
extensible and allowed for more configuration as to how guards and
resolvers are run.
Closing in favor of #42953
<#42953>
—
You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub
<#10556 (comment)>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/AAG47RD2WZIDJ5PFCSTX6ZTTZHTJNANCNFSM4CL2KY6A>
.
|
@atscott I do agree the premise of resolvers is to have the data before component instantiation. This is still true in my example. However, there are many cases where I want to receive additional updates to the data my components can respond to. This is what I use the HotResolver for. It verifies initial data was loaded and I am able to continue to listen to that data for changes. This still seems like a very valid use of a resolver to me. Initial data should be required, but that does not mean the observable has to be completed. In our case, the data updates are coming through Web Sockets, and the URL will never change for the resolve. Maybe the change here would be to simply use first() on a resolver instead of requiring a completed observable. #42953 does not seem to address this particular use case of resolvers. |
@gjensen-gomeyra The intent behind #42953 would be that you would have a way to completely rewrite how resolvers are handled. So rather than having to fork the entire router package, you could for just the resolver code and swap the resolve stage out to your own implementation. It's not necessarily that we would implement the feature into the router for you, but rather give developers the option to define this behavior themselves. This would be a much more sustainable model moving forward rather than trying to bake in each particular use-case. |
@atscott I do have it working using a wrapper, as shown above. I just thought it would be useful to other users. I can also create a library that developers can add to their projects. Maybe that would be the right route to go. |
@greggbjensen 's case is my main case as well. Similar to @greggbjensen , I've also already integrated a workaround for this.
@atscott 's closing comment didn't demonstrate understanding of this specific issue/thread, but the follow-up to @greggbjensen on July 26 did. Thank both of you for that. I do appreciate that Observable was chosen for HTTP calls because of the ability to chain cancellations and similar, and router today basically expects it to behave as a cancellable promise. Therefore, I do see how this can be considered "as-designed". It's very confusing in the current architecture to route hot subscription data into components, and it seems like a common feature to provide. That is the main thrust of this issue. I hope #42953 allows for that, whatever its design entails. |
This issue has been automatically locked due to inactivity. Read more about our automatic conversation locking policy. This action has been performed automatically by a bot. |
This is probably best classified as a Router feature request. I'll describe what it is, my thinking, and a use case.
What it is: Currently, the V3 Router waits for an Observable to Complete before completing navigation. I would like for it to instead continue navigation after the first Observable item is returned.
My thinking: Currently the Resolve treats the Observable as a Promise, defeating the power of the Observable. This doesn't present a barrier with Angular's Http, where only one item is returned. But unless I misunderstand, the point of using an Observable in Http is to allow easy extension throughout Angular into a scenario where the data is actually a stream.
Use case: I have a live data feed from a remote server. I subscribe to a resource, rather than get it. When viewing this resource in a page, I only need one snapshot to complete navigation and display the component, but I would still like the updates bound to the view.
More detail:
Observables as first-class citizens in Angular2 are great for displaying "live data". I currently rely on, and it is becoming more common to integrate, "live data" streamed/syndicated/published from web services. Regardless of the framework used to deliver streams to the client, Observables are the obvious choice to act as intermediary between the receiving agent/service and the view.
Resolvers in Angular are also very nice for a few reasons. One is that I can be sure I have all my data before performing initialization. This greatly simplifies my component initialization logic. It also simplifies managing built-in operations Angular performs, such as avoiding errors binding templates to properties on null models.
But where live data comes in, the current Resolve behavior is difficult to make use of. If we suppose the live data stream never completes, we have to instead resolve, for example, to Observable.first(), and throw away the remainder of the stream.
Or I can go through extra steps for each of my Observables, to Observable.publish etc. But that's not enough, because there's not a symmetric exit hook for resolve, that I see. So instead I have to do some extra magic, such as tear down resources I didn't initialize in the component.
IMO, in the spirit of Rx, a better behavior would be to:
Or something similar.
Your thoughts are appreciated. I'll also be happy to contribute effort if someone with design authority confirms an approach.
The text was updated successfully, but these errors were encountered: