Skip to content

Better ways to manage prerendering+JSInterop interactions #8786

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
SteveSandersonMS opened this issue Mar 25, 2019 · 18 comments
Closed

Better ways to manage prerendering+JSInterop interactions #8786

SteveSandersonMS opened this issue Mar 25, 2019 · 18 comments
Assignees
Labels
area-blazor Includes: Blazor, Razor Components Done This issue has been fixed enhancement This issue represents an ask for new feature or an enhancement to an existing one

Comments

@SteveSandersonMS
Copy link
Member

SteveSandersonMS commented Mar 25, 2019

Background

I've tried to collect all the issues related to this. Here's what appears to be the full set:

Scenarios

From all the descriptions and comments in the above issues, I extract the following scenarios:

  • [A] During prerendering, I want my component to render different output (or a blank space). Once we switch into interactive mode, it should render again in regular (non-prerendered) mode.
    • e.g., to render in "ghosted out" mode while non-interactive
    • e.g., to skip certain computations or data access that we don't want to block the initial HTML response on
  • [B] I have a component that needs to do JSInterop as part of its initialization, so I want to defer those JSInterop calls until JSInterop becomes available.
    • e.g., to initialize a JS widget library that gets attached to my component's rendered DOM elements
    • e.g., to invoke some JS logging library that doesn't make sense to call during prerendering

Possible solutions

  • [1] We could have a DI service, e.g., @inject IPrerenderingContext PrerenderingContext that lets you evaluate PrerenderingContext.IsPrerendering anywhere in your lifecycle methods or rendering logic
    • As of today, that would cover both scenarios A and B, since we create an entirely new set of components when switching from "prerendering" to "regular" execution. However once we implement proper stateful reconnection, this would no longer suffice, because the retained components wouldn't have an easy way to know that IsPrerendering has changed from true to false.
    • Doesn't help with scenario B
  • [2] We could have a component like <PrerenderingStateProvider> that cascades down a value of type IPrerenderingState with a similar bool flag.
    • This is similar to the DI service, except it automatically does trigger re-rendering on change
    • For scenario B, you could technically override OnParametersSetAsync, and keep track of whether it's the first time you've seen IsPrerendering==false, and if so do your JSInterop call. It would work but is pretty inconvenient and hard to guess.
  • [3] We could have a new component lifecycle method, e.g., OnInteractiveAsync, that runs exactly once when interactive mode first starts.
    • For scenario A, developers could use this to toggle their own "isPrerendering" flag and then trigger StateHasChanged. This means the component doesn't have to re-render if you're not trying to have different outputs in the two modes.
    • For scenario B, you'd just put your JSInterop calls into this new lifecycle method and be done
  • [4] We could have some component-level attribute like [SkipDuringPrerendering] that means you get blank output during prerendering, but we also track the need to render that component once we switch into interactive mode.
    • Only solves A if your goal was to render a blank space during prerendering.
    • Solves B in that your OnAfterRenderAsync lifecycle method would now only fire after we switched into interactive mode.
  • [5] We could have some "component context" or "rendering context" object (analogous to HttpContext) made available to components either as a DI service or as a cascaded value. In turn, this could have an IsPrerendering flag.
    • This is basically the same as either [1] or [2], but less fine-grained.

Even though [2] does take care of "knowing when to re-render", I'm not attracted to it because of the perf implications. It would mean that every component that experiences scenario A or B has to register/unregister a listener for the cascaded value update, which if it's all the components in a UI library (e.g., Material design, which needs each component wired up to some JSInterop on init) could be a lot of such instances all at once. Moreover, it forces all such components to re-render once they become interactive, even if they don't need to (e.g., because they only have scenario B and not A, e.g., the UI library again). Option [5] also suffers this, but even worse since it would have to notify and force re-rendering even when unrelated contextual information changes.

I think we can basically ignore [4] too, since it's too blunt an instrument - it doesn't let you render different content during prerendering.

We also see that [1] is not sufficient on its own.

The one I think has most promise is [3]. Generally we strongly try to avoid creating new lifecycle methods, but we were expecting to create one new one for OnAfterFirstRenderAsync anyway, i.e., #7842. We could combine the needs of #7842 with the JSInterop+prerendering scenarios, and have the rule that it only runs when interactive (including if switching from prerendered to interactive). That is precisely when you want to run your code for scenario B.

cc @rynowak @javiercn for further design thoughts.

@SteveSandersonMS SteveSandersonMS self-assigned this Mar 25, 2019
@SteveSandersonMS SteveSandersonMS added 1 - Ready enhancement This issue represents an ask for new feature or an enhancement to an existing one area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates area-blazor Includes: Blazor, Razor Components labels Mar 25, 2019
@SteveSandersonMS SteveSandersonMS added this to the 3.0.0-preview4 milestone Mar 25, 2019
@RichiCoder1
Copy link

I need to think of a good example, but would it be possible to have both [1] and [3]?

@javiercn
Copy link
Member

I think the main pain point here is when there is logic that needs to run on OnParametersSetAsync that requires performing a JSInterop call or similar and retrieve results in order to procceed.

Initializing JS for an element can be done as you said in OnAfterRender if we are willing to live with making the user keep track of it. As an alternative we might be able to subsume it inside ElementRef by having something like ElementRef.Initialize(()=>...) and have that only be run once for the lifetime of the element ref (by virtue of keeping an _isInit flag on elementref)

For a component that needs to render its contents based on some js interop result (for example a component that needs to call into local storage) this is a bit trickier. Perhaps, instead of having a service with a flag, we could have a service with a flag+callback? That way you would do something like

if(!prerenderService.IsPrerender){
  InvokeLogicCore();
}else{
  prerenderService.Register(()=> Invoke(() => InvokeLogicCore();StateHasChanged();), prerender:false);
}

Alternatively

T _myInitTask
if(!prerenderService.IsPrerender){
  await InvokeLogicCore()
}else{
_myInitTask = InvokeDeferred(prerenderService);
}

InvokeDeferred(prerenderService){
 await prerenderService.Interactive;
 await InvokeLogicCore();

Just ideas. The main drawback of the cascading value is the perf of everyone registering for it no matter what. If we follow one of these other patterns, that goes away (you still need to inject the service).

@SteveSandersonMS
Copy link
Member Author

I think the main pain point here is when there is logic that needs to run on OnParametersSetAsync that requires performing a JSInterop call or similar and retrieve results in order to procceed.

This is covered by scenario A above, in that during prerendering you simply can't use JSInterop call output, so you need to render something different. After switching to interactive mode, you become free to make your JSInterop call and then render based on the results of that.

@javiercn
Copy link
Member

After switching to interactive mode, you become free to make your JSInterop call and then render based on the results of that.

Yep, I'm not disagreeing with you. I'm giving you alternatives to introducing an additional method. Ultimately, what prevents you from calling StateHasChanged (properly protected by a guard) in OnAfterRenderAsync?

How do we model this for the most general case where the client gets disconnected from the server? You are going to have the same problem if you check periodically something using js interop.

@SteveSandersonMS
Copy link
Member Author

Ultimately, what prevents you from calling StateHasChanged (properly protected by a guard) in OnAfterRenderAsync?

Nothing stops you. That’s exactly how you would deal with scenario A. Only difference is it’s not OnAfterRenderAsync, it needs to be a different event representing the transition into interactive mode.

About later disconnection, I don’t consider that part of these scenarios. It’s exactly like periodically invoking some remote HTTP API. You have to handle failures if it’s not fire-and-forget.

@javiercn
Copy link
Member

Nothing stops you. That’s exactly how you would deal with scenario A. Only difference is it’s not OnAfterRenderAsync, it needs to be a different event representing the transition into interactive mode.

Only because you want it to happen once? In the two implementations we care about, OnAfterRenderAsync only gets called after the display has been updated, at which point you have become interactive, hence my point of simply doing:

bool _initialized = false;

void OnInit(){
  if(JSRuntime.IsAvailable()){
    _initialized = true;
    CompleteInitialization();
}

void OnAfterRender(){
    if(!_initialized){
       _initialized = true;
       CompleteInitialization();
       StateHasChanged();
    }
}

If you don't think this is good enough then another method is required, but given that we don't want to add an extra method if we can avoid it, i would consider just providing guidance.

Having to override two methods is a bit cumbersome, but how common is this case otherwise?

@javiercn
Copy link
Member

Finally, I leave the decision up to you. I'm trying to play a bit of devils advocate here, but I know you'll do the most sensible thing.

@SteveSandersonMS
Copy link
Member Author

I'm trying to play a bit of devils advocate here

That's great, I totally appreciate it. Thanks for adding your thoughts and questioning the design concepts!

I'm entirely open to using OnAfterRenderAsync as the lifecycle method for this, but it would involve changing its meaning. Currently prerendering executes OnAfterRenderAsync immediately after prerendering. We'd need to change this so it doesn't execute until the client reconnects and reapplies the prerendered batches. Apart from the change of meaning, the other potential drawback is that in the prerendering+Blazor case, OnAfterRenderAsync would never execute on the server at all, which could either just confuse developers or risk leaks if they were relying on it e.g., to dispose things constructed at the start of rendering. That's why I thought it clearer to have an explicit method (OnInteractiveAsync) for it, which can also fix the need to track the _initialized flag in your sample.

Were you specifically planning to change this OnAfterRenderAsync behavior as part of your prerendering+reconnection work?

Also, @danroth27 @rynowak - do you have views on whether OnAfterRenderAsync should only execute after interactive mode starts, or would you have guessed the existing behavior (runs straight away after prerendering, whether or not the client then opens a circuit)? I think I would probably have guessed the latter, but could be persuaded that either is OK.

@rynowak
Copy link
Member

rynowak commented Mar 27, 2019

It's going to take me a few minutes to catch up on this thread - which I need to do anyway 👍 Hold tight

@danroth27
Copy link
Member

danroth27 commented Mar 27, 2019

do you have views on whether OnAfterRenderAsync should only execute after interactive mode starts, or would you have guessed the existing behavior (runs straight away after prerendering, whether or not the client then opens a circuit)?

I think the existing behavior of OnAfterRenderAsync is pretty intuitive.

Instead of trying to create a broad abstraction for prerendering vs interactivity, have you considered instead having some way to detect if specific services are available for use? For example, what if there was an API (maybe on IJSRuntime?) that let you check specifically if JS interop was currently available?

Actually never mind - you would still need some sort of event when component can decided what to do.

@javiercn
Copy link
Member

javiercn commented Mar 27, 2019

Were you specifically planning to change this OnAfterRenderAsync behavior as part of your prerendering+reconnection work?

You are right. Given that’s the case it probably makes more sense to make it run after the client has reconnected.

If you are only prerendering the two main cases we are trying to enable are not available (hook js to html element, initialize something based on a JS call)

If you need to dispose resources the right thing is to implement IDisposable.

OnAfterRender should be consistent between client and server so that libraries don’t have to account for the difference. For that reason I think it’s better if it only runs after you have rendered the html in the browser and have populated element refs, etc.

@pranavkm
Copy link
Contributor

pranavkm commented Mar 27, 2019

We could have a new component lifecycle method, e.g., OnInteractiveAsync, that runs exactly once when interactive mode first starts.

Is it odd for this event to exist on Component when it typically only applies to one kind of Components (server-side rendererd)?

@SteveSandersonMS
Copy link
Member Author

Is it odd for this event to exist on Component when it typically only applies to one kind of Components

Whichever way we do it, it will apply both server and client side. In the WASM case, the event will simply occur immediately after rendering (possibly only the first render for a given component, depending how we design it). So the developer’s code would work in both environments.

@rynowak
Copy link
Member

rynowak commented Mar 28, 2019

We're not planning to have a separate DI container for prerendering as opposed to other server-side execution

Yeah, I don't think we should consider seriously providing different implementations of services depending on context. This would be a complexity explosion for us.

[A] During prerendering, I want my component to render different output (or a blank space). Once we switch into interactive mode, it should render again in regular (non-prerendered) mode.
[B] I have a component that needs to do JSInterop as part of its initialization, so I want to defer those JSInterop calls until JSInterop becomes available.

Thanks for listing these, I think the second one was a little less clear in my mind and this helps immensely.

Do you think that scenario B also includes things like starting a timer or launching HTTP requests? I'm wondering if scenario B is really just about prerendering or about all of these connected/disconnected scenarios in general.

In terms of the overall analysis, I think you're pretty much on. I know I suggested the idea of a cascading value, but this feels like a more fundamental concern. I also want to know more about the OnAfterFirstRender thing, and whether we could try to use a single lifecycle method to solve all of these things.

I feel like we're accumulating more at a pretty rapid rate, but they all feel first class to me except the OnAfterFirstRender thing. I think it would be acceptable to add a callback for interactive as a first class item, because its part of the programming model for both Blazor and Server-Side. I think what you're really arguing for here is the idea that interactivity is a separate lifecycle from rendering, and that seems right at least in principle.

I would add an idea to these that we add to ComponentBase either an IsInteractive or IsPrerendering property. That's probably the simplest solution we could provide for scenario A. I think we should consider this in-addition-to one of the other proposals.

I'm spitballing a few alternative designs just to have some alternatives considered (and probably rejected). If you want to proceed with option 3, you'd have my 👍

Would it make sense to do something like this:

protected override Task OnInitAsync(ComponentContext context)
{
    context.RegisterAfterRenderCallback(OnAfterFirstRender, once: true);
    context.RegisterAfterRenderCallback(OnAfterFirstInteractiveRender, interactive: true, once: true);
}

We initialize you with a context, and you can register callbacks where you get to decide the semantics. I'm hoping/speculating that this has a similar cost to what we'd need to do to support the alternatives.

Another alternative, maybe covered by your option 5 is to just make all our lifecycle methods take a struct context that exposes stateful properties from ComponentState. This involves writing some ifs in your code, but I don't understand why if is such a bad thing.

Another option would be to do the same thing, but expose those stateful properties on ComponentBase itself.

@javiercn
Copy link
Member

I'm all up for solving this with guidance I think. Saying that you use OnAfterRender for that.

  • [A] During prerendering, I want my component to render different output (or a blank space). Once we switch into interactive mode, it should render again in regular (non-prerendered) mode.

The guidance would be to not render anything on OnInitAsync and do it instead on OnAfterRender with a flag and call StateHasChanged from there. I'm not sure how common this scenario is, so if you need to do it 1 for every 20 components I think it's fine. We can also have an analyzer tell you this or even refactor the code for you. (The transformation is pretty mechanical)

  • e.g., to skip certain computations or data access that we don't want to block the initial HTML response on

In this case you would fire a task and call StateHasChanged from inside it.

  • [B] I have a component that needs to do JSInterop as part of its initialization, so I want to defer those JSInterop calls until JSInterop becomes available.

This should always be possible in OnAfterRenderAsync if not, this is an implementation bug. We should have a consistent minimum guaranteed experience for lifecycle methods across platforms and enforce that in our tests.

For example:

  • JS interop might not be available during OnInit/OnParametersSet but will always be available on OnAfterRenderAsync.

  • OnAfterRender async only runs when the "client" has finished updating the display (whether synchronously or asynchronously) and might not run at all if you are only prerendering.

This also bring in the question. If you are only prerendering (you are creating a static snapshot of the site for some reason). Should we make that information available to you somehow so that you can make a different decision? (Like not ghosting out the component and producing the actual render)

@SteveSandersonMS
Copy link
Member Author

Do you think that scenario B also includes things like starting a timer or launching HTTP requests?

No I wasn't thinking that, because you're free to do that even before interactivity has started. The distinctive thing about JSInterop is that you can't do it until the client has connected.

I also want to know more about the OnAfterFirstRender thing

I'm not crazy about having a special OnAfterFirstRenderAsync either. But enough people have wanted this now (including me, based on experience of component library authoring) that perhaps I'd suggest a middle ground: we keep just a single OnAfterRenderAsync, but make it OnAfterRenderAsync(bool isFirstRender) arg to it. Like you say, if is not a bad thing. Anyway we don't have a do this (or commit to a conclusion about it) right now - it's somewhat separate.

I would add an idea to these that we add to ComponentBase either an IsInteractive or IsPrerendering property

I'm certainly open to that, and appreciate the simplicity. It does potentially lead us down a path of having a whole suite of properties like you have on Controller, e.g., User, Uri, JSInterop, and so on, so it's not 100% obvious where you draw the line about what should be a DI service. It also needs to be testable, so either these have to be publicly settable or there has to be some other way for unit tests to control the values of these properties.

We initialize you with a context, and you can register callbacks where you get to decide the semantics

This design looks OK, but I don't see significant advantages versus having overridable lifecycle methods. Overridable lifecycle methods are easier to code through intellisense prompts and are less allocatey.

Overall, the design I feel best about in this discussion is:

  1. To handle the "notify me when I am interactive" needs, @javiercn's idea where, in the prerendering case, OnAfterRenderAsync runs only after the browser has completely finished the render process, which includes connecting the circuit and re-applying the prerendered batches (e.g., to attach event handlers to elements). Reasons:

    • If we do that, there's no need to change the set of lifecycle methods
    • And in scenario B at least, it gives developers no way to make any new mistakes (they already have to defer usage of ElementRef until after-render, so as long as they do that, it will automatically work with prerendering too). They just can't get it wrong.
    • Drawback: In the prerendering only (non-interactive) case, OnAfterRenderAsync would not fire at all. If people understand this that's fine (as there are no scenarios I can think of where you really want OnAfterRenderAsync in a non-interactive rendering case), but it might be misleading. I'd be open to renaming to OnInteractiveAsync still.
  2. To handle the "knowing whether I am currently prerendering" needs, addressing the slightly more general question of "knowing whether I am currently connected". That is, either have a DI service like IComponentContext with an IsConnected bool prop (where the instances would be identical across all components, not some kind of per-component thing), or have a bool IsConnected property on ComponentBase that gets its result by invoking some IsConnected() delegate that is supplied to IComponent.Configure during initialization.

    • I'm leaning towards the DI service instead of the ComponentBase property. There's not much in it - either would work really - but the DI service will perform better on the assumption that only a tiny minority of components will want to receive it (because all the others see zero extra setup cost), plus it's more obvious how people will interact with it during unit tests. Please speak up if you disagree.

@SteveSandersonMS
Copy link
Member Author

Oh sorry @javiercn, I posted without realising you had already added a new comment. Apologies if it seems like I was ignoring you. But having read your comment now I basically agree with all you've said there and think my comment aligns with yours.

If you are only prerendering (you are creating a static snapshot of the site for some reason). Should we make that information available to you somehow

I don't think we need to do anything. If a developer is doing this, they know it themselves and can already put in their own logic for that.

@SteveSandersonMS
Copy link
Member Author

Closing this, as the part I'm doing is done in #8888, and the remainder is covered by @javiercn's work in #8911.

@SteveSandersonMS SteveSandersonMS added the Done This issue has been fixed label Mar 29, 2019
@mkArtakMSFT mkArtakMSFT removed area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates labels May 9, 2019
@ghost ghost locked as resolved and limited conversation to collaborators Dec 3, 2019
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
area-blazor Includes: Blazor, Razor Components Done This issue has been fixed enhancement This issue represents an ask for new feature or an enhancement to an existing one
Projects
None yet
Development

No branches or pull requests

7 participants