Skip to content

Design considerations for libraries based on JS Interop #13296

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

Closed
rynowak opened this issue Aug 20, 2019 · 14 comments
Closed

Design considerations for libraries based on JS Interop #13296

rynowak opened this issue Aug 20, 2019 · 14 comments
Labels
area-blazor Includes: Blazor, Razor Components
Milestone

Comments

@rynowak
Copy link
Member

rynowak commented Aug 20, 2019

The below text was posted by @egil on #12082 - moving it here so we can continue the discussion.

OK @rynowak, let me share my current thoughts, maybe you can fill in a few gaps :)

My scenario is primarily a component library/service library setting. Ideally, since Razor components leverages dependency injection, the users of my library doesn't need to know or should know whether or not the services or components in my library uses JSInterop. If they do, they would, like you say, have to be aware and handle of the various pitfalls with JSInterop.

If I later remove a components or services dependency on JSInterop (you guys might release new APIs later that means I don't need it), then all my users who have been good Razor citizens and added proper error handling for JSInterop, they would likely need to clean up their code again.

With the above in mind, I will as a library author always prefer to handle the expected JSInterop errors, and wrap the unexpected JSInterop errors in a custom exception type, that generally signal to users of my library that something unexpected happened. That way, the users of my libraries can have a more simplified error handling, or none at all, and at least one that is less likely to change.

Known or expected errors

This is where you can probably help -- what are the all the things that can go wrong in JSInterop world, and how to recognize each one?

  1. Prerendering - JSInterop is not available. Most likely cause of action is to simple continue and not do anything.
  2. JSInterop call doesn't go through or doesn't complete. Cause of action depends on scenario, but a circuit breaker approach could be a possibility in some cases. At least retry N times with X delay.
  3. Called JS function doesn't exist (variant of 2. option). Cause of action could probably try again after a short delay, since browser might not have completed downloading/parsing the source javascript file, then after a little while throw an exception
  4. Mangled response. Cause of action is very dependent on context. If we worry about security implications due to the scenario, then maybe throw an cannot continue exception and have the user of the service or component decide what to do. If it is more benign, like my case with the Page Visibility API, then maybe simple ignore the response and wait for a next one.

There might be other considerations for client side blazor, and I cannot claim to have any experience with SingalR directly, so there might also be some protocol-level errors that can be handled gracefully, depending on scenario/context.

Thoughts?

@rynowak rynowak added this to the Discussions milestone Aug 20, 2019
@rynowak rynowak added the area-blazor Includes: Blazor, Razor Components label Aug 20, 2019
@rynowak rynowak self-assigned this Aug 20, 2019
@egil
Copy link
Contributor

egil commented Aug 20, 2019

Thanks @rynowak (although I hope my text does not "blow" 😎)

@rynowak
Copy link
Member Author

rynowak commented Aug 20, 2019

Fixed lol. Sorry for the bad typo.

My scenario is primarily a component library/service library setting. Ideally, since Razor components leverages dependency injection, the users of my library doesn't need to know or should know whether or not the services or components in my library uses JSInterop. If they do, they would, like you say, have to be aware and handle of the various pitfalls with JSInterop.

It sounds like you already figured out the solution - if you want to totally abstract away the fact that JS Interop is used here, you're going to to want to create your own exception type and throw that consistently. Consumers of your library will need to be aware that it can fail, just like anything that's network-based.


Prerendering - JSInterop is not available. Most likely cause of action is to simple continue and not do anything.

The JS Runtime will throw during prerendering. My intuition is that this is a bug in your caller's code - and thus you should just let this bubble up. Component authors need to be aware that they can do a limited set of things during prerendering. Prerendering is not for everyone and not for every scenario.


JSInterop call doesn't go through or doesn't complete. Cause of action depends on scenario, but a circuit breaker approach could be a possibility in some cases. At least retry N times with X delay.

For cases where the circuit is disconnected - we currently throw an InvalidOperationException. This isn't great, because I don't really ever want to see code that handles invalid operation exception.

There are two things that give me pause thinking about disconnected states:

  • We're not totally sure how important/common it is. As-in, we have support for reconnection, but we're not sure how often a JS interop call is going to be interrupted by connection status.
  • It's unclear what you'd do about it other than loop and retry. If we provided you a way to await connected then that seems like it might help, but it would still be subject to race conditions.

We'll also throw OperationCancelledException for cancellation and timeouts.

One thing we might consider here is to track whether or not the connection is open internally to the JS Runtime, and have an await connected paired with our timeout. This is still kinda racy - but trying to start work while disconnected will hit a timeout instead of blowing up immediately, giving you backpressure.


If the JS side throws an exception we return that in a JSException - https://github.com/aspnet/Extensions/blob/master/src/JSInterop/Microsoft.JSInterop/src/JSRuntime.cs#L182 - my guess is that we go down this code path for a non-existent JS function as well - your case 3.

When JS code returns bad data, we throw a JSException but with the inner exception set to System.Text.Json.Exception https://github.com/aspnet/Extensions/blob/master/src/JSInterop/Microsoft.JSInterop/src/JSRuntime.cs#L187

@rynowak rynowak removed their assignment Sep 3, 2019
@egil
Copy link
Contributor

egil commented Sep 4, 2019

@rynowak thanks for the input. Been busy at the (paying) job so completely forgot about this.

I will see how I can use you say to build up a stable service and components now that P9 is about to land.

@SQL-MisterMagoo
Copy link
Contributor

@rynowak thanks for opening up this discussion. I'll try to map out a scenario where IComponentContext made things a bit better.

So, a simple example would be an IClientRegionService that is obviously in the DI container as a specific implementation - this service needs to read some information from the client browser (let's not worry about how valid the information is - for our purposes it works great) - for example it gets the current local time from the client, to work out an offset for displaying localised times without the need for a JS lib on the page.

In order to do this successfully the service needs to use JSInterop and therefore needs to know when the client is connected.

The only lifecycle event we have would require a Razor Component that implements OnAfterRenderAsync and notifies the service that we are connected.

So, to implement a service that provides localized information about the client, we are dependent on the application having a component that will be present in all layouts, but who's only job is to control the lifecycle events of a DI service.

It wasn't ideal with IComponentContext either as there was still nothing we could await but at least it was possible to have an independent service that could detect for itself when it was safe to use JSInterop.

I accept that it is possible to work this way, with a Component that does the job, but it would just be really nice to have that separation, so Services wouldn't have to rely on the lifecycle of a component.

Of course the service could just keep trying JSInterop and handling errors, but I'd rather not do that if it can be avoided.

A further thought is that I can easily imagine that it would be useful to know whether Blazor is connected in other parts of an application where Blazor forms only part of the surface, for instance if you have an MVC application that uses Blazor Components embedded in a view?

I guess it would just be really useful to have a scoped service in the DI container that we can Inject and provides some kind of connection monitor.

@egil
Copy link
Contributor

egil commented Sep 4, 2019

@SQL-MisterMagoo I think your scenario is somewhat similar to the one I posted in the original issue, adding it here for additional context: #12082 (comment)

@SQL-MisterMagoo
Copy link
Contributor

@egil Yeah, I guess so, I think my underlying feeling is that it's not 100% components that need to care about connected state.

@Yen
Copy link

Yen commented Sep 4, 2019

We are using a system that gets the clients timezone on blazor server side for use in components. At first it was done by a service that defaulted to a predefined timezone when IComponentContext was not connected. Now it has been moved over to a cascading parameter in the main layout (arguably a better API anyway), but still faces the same issue with pre-rendering where we have to use a default fallback value or handle null cases. Both methods add a lot of extra consideration with changing values or handling of cases that are only really there for initialisation.

I would like to see a way to...

  1. Opt out of pre-rendering

  2. Perform pre-rendering from a lifecycle perspective, but have this happen after the connection is established, then we can use JSInterop within rendering and initilisation operations. I think here its fair to have a consideration that can be made by the user with JSInterop as to if their components will be rendered or not depending on what lifecycle methods they are using. If a user needs the component to be rendered, their logic should reside in the OnAfterRender[Async] methods for example.
    This can almost be tied to IJSRuntime itself. Have everything prerender as normal, but if InvokeAsync it called, wait for the connection to finish establishing before continuing. (This is assuming the prerendering and establishing of the connection are done asynchronously in blazor, which I don't believe the are at the moment just by observing how the client can lock up with long prerendering operations)

  3. Have a well defined understanding of when we can and can't use JSInterop from a service perspective the same as was possible with IComponentContext, preferably with an event too.

If you can't tell by the effort distribution here, option 2 is my preferred method :)

Thanks for letting me not write javascript!

@egil
Copy link
Contributor

egil commented Sep 4, 2019

@Yen from preview 9, OnAfterRender* won't be called during prerendering anymore, so that should solve your main issue. There are however other reasons for JavaScript interop to fail, so you will probably still need some defensive code that does retrying for temporary issues.

@Yen
Copy link

Yen commented Sep 4, 2019

@egil I am not sure of the rational behind that decision, it seems to me that it just makes code more complex as the "OnAfterRender" function isnt being called after rendering, but that's a separate issue to this I think.
The problem I think I have with the current system is there is no way to invoke JSInterop before or during a prerender. I am aware that it is possible to work around all of these things with the correct lazy intialisation logic within components, but its not really a practical approach, and many solution that look right on paper, actually end up causing subtle issues.

For an example with my above solution, I had issues where I would assign a value derived from the fallback default timezone, then end up comparing it with another value derived from the now correctly updated cascading parameter.
I am aware that what I had wrote, and the intended behaviour, is for it to work like this; but there is no good alternative as far as I can see. 🤔

@Janisku7
Copy link

Janisku7 commented Sep 6, 2019

with HotSwap component the IComponentContext came critical for me as user can select some components on specific service so if there is no connection to backend service all those components dont have componentContext that time so no render and when connection is establish the rendered when those components Context is not null

@rynowak
Copy link
Member Author

rynowak commented Sep 6, 2019

Can I ask for info about specific scenario details? Everyone here seems to be building services that are intended to be reusable. What do those services do?

Having the real scenarios in mind will help us design solutions to this.

@Yen
Copy link

Yen commented Sep 6, 2019

@rynowak I need a service server side that can provide a js call to get the current browser timezone before prerender. Server side specifically.

Thanks

@egil
Copy link
Contributor

egil commented Sep 6, 2019

Can I ask for info about specific scenario details?

@rynowak: My service-scenario is this - PageVisibilityService.cs / pageVisibilityApiInterop.js.

With tabbed browsing, there is a reasonable chance that any given webpage is in the background and thus not visible to the user. The Page Visibility API provides events you can watch for to know when a document becomes visible or hidden, as well as features to look at the current visibility state of the page. - Page Visibility API at MDN

This is really useful if you have any timers that updates the UI in your Blazor app, since the you can stop your timer and save ressources, when the users does not have the browser tab showing your app in the foreground.

My implementation works like this:

  1. Components gets the service injected into them.
  2. They can subscribe to "Visibility" changes through a regular .net event (OnPageVisibilityChanged)
  3. When the first subscriber subscribes, my service will try to establish a subscribing to the relevant DOM events related to the Page Visibility API through a JavaScript call.
  4. When the last subscriber unsubscribes, my service will unsubscribe from the JavaScript Page Visibility APIs DOM events.
  5. If the browsers visibility status changes, the browsers related DOM event is raised, propagated to my service, who in turn notifies its subscribers through a regular .net event.

This flow ensures that there is only one subscription per service-scope, i.e. browser tab, independent of how many components wants to track the browser tabs visibility.

The components also do not need to know if the browser supports the Page Visibility API or if my service is able to establish a subscription to it, since my service will tell subscribers that the page is visible unless it explicitly knows otherwise.

I dont know of the top of my head how many similar DOM APIs that exists in the browsers, but I think that quite a few of those would follow a similar pattern to that described here.

Let me know if anything is unclear.

Ps. the service is currently part of my effort to build a Bootstrap razor component library, but I will likely pull it out to an independent library in the future.

@ghost
Copy link

ghost commented Nov 12, 2020

Thank you for contacting us. Due to a lack of activity on this discussion issue we're closing it in an effort to keep our backlog clean. If you believe there is a concern related to the ASP.NET Core framework, which hasn't been addressed yet, please file a new issue.

This issue will be locked after 30 more days of inactivity. If you still wish to discuss this subject after then, please create a new issue!

@ghost ghost closed this as completed Nov 12, 2020
@ghost ghost locked as resolved and limited conversation to collaborators Dec 12, 2020
This issue was closed.
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
area-blazor Includes: Blazor, Razor Components
Projects
None yet
Development

No branches or pull requests

5 participants