Skip to content
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

Interactive component looses state on Navigate to same page #52356

Closed
1 task done
Nephim opened this issue Nov 24, 2023 · 9 comments
Closed
1 task done

Interactive component looses state on Navigate to same page #52356

Nephim opened this issue Nov 24, 2023 · 9 comments
Assignees
Labels
area-blazor Includes: Blazor, Razor Components
Milestone

Comments

@Nephim
Copy link

Nephim commented Nov 24, 2023

Is there an existing issue for this?

  • I have searched the existing issues

Describe the bug

This might be the same/related to Issue #51584.

I'm having issues refactoring an application going from Blazor server to the new SSR + InteractiveAuto mode.

I have a main page where i fetch all the data from my backend in the OnAfterRender if its firstRender.

This page then has a button that navigates to the same page but with different parameters. All of this works.

However on Blazor server the state of the component was kept and only re-rendered with new parameters.

Counter Component

@page "/counter/{Count:int?}"

<PageTitle>Counter</PageTitle>

@if(Count is not null)
{
    <ComponentDetails Count="@Count" />
}
else
{

    <button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

    <button @onclick=@(() => NavigationManager.NavigateTo($"/counter/{currentCount}"))>SubPage</button>
}

@code {
    [Parameter] public int? Count { get; set; }
    [Inject] private NavigationManager NavigationManager { get; set; } = default!;
    protected override void OnAfterRender(bool firstRender)
    {
        if(firstRender)
        {
            Console.WriteLine("Backend called!");
        }
    }

    private int currentCount = 0;

    private void IncrementCount()
    {
        currentCount++;
    }
}

ComponentDetails

@rendermode InteractiveAuto
<h3>ComponentDetails</h3>

<p>@Count</p>


@code {
    [Parameter] public int? Count { get; set; }
}

In the new Dotnet 8 Rendering mode the Component is "thrown away" and a whole new component is rendered which then has to fetch data from the backend all over again.

Initial Load:
image

Navigation:
image

This might not be a bug but I've been unable to find a way to handle this. The docs mention something interesting but I haven't been able to find how such a thing would work.

Enhanced navigation and form handling

"If enhanced navigation is available for interactive client routing, navigation always goes through the interactive client-side router. This point is only relevant in an uncommon scenario where an interactive is nested inside a server-side rendered . In that case, the interactive router takes priority when handling navigation, so enhanced navigation isn't used for navigation because it's a server-side routing feature."

Expected Behavior

The navigation should not fetch a new page and should just re-render the current component with new parameters.

.NET Version

Dotnet 8

@dotnet-issue-labeler dotnet-issue-labeler bot added the area-blazor Includes: Blazor, Razor Components label Nov 24, 2023
@javiercn
Copy link
Member

@Nephim thanks for contacting us.

That section of the docs is confusing. We'll get it fixed/sorted out.

The TL;DR is:

  • SSR works like a traditional web page (razor pages, mvc).
    • State is not persisted across navigations (whether enhanced or not).
    • To preserve a component you need to pass in a @key to it that is stable across the current and the new document. Otherwise, I believe we destroy and recreate the component. @MackinnonBuck can add details here.
  • When the app has a client-side router (global interactivity) routing happens in two places:
    • The first time on the server.
    • After that, the client-side router takes over.
      • State and components are preserved in this situation.

@guardrex can you update the mentioned paragraph to make it clear that enhanced navigation and client side routing are different unrelated things?

Particularly this part:
"If enhanced navigation is available for interactive client routing, navigation always goes through the interactive client-side router.

And the other part that talks about the routers nesting. They don't nest.

@guardrex
Copy link
Contributor

guardrex commented Nov 24, 2023

On the PR, @MackinnonBuck said ...

But it should be noted that even if enhanced navigation is available, navigations will still go through an interactive (client-side) router if one exists.

... so I asked ...

why is that important to tell readers?

... @MackinnonBuck said ...

It's probably not going to be relevant to most customers. It really only applies if you were to have, for example, an interactive <Router> nested inside a server-side rendered <Router>. In such a case, the interactive router would take priority when handling navigations, so enhanced navigation wouldn't be used for navigation (since that's a server-side routing feature).

... but also that ...

It's possible this is a detail we can leave out, since that's a very uncommon pattern

Cross-ref: dotnet/AspNetCore.Docs#30336

I left it in (reworded) ... BTW @Nephim's post lost the code-fenced <Router> parts ...

If enhanced navigation is available for interactive client routing, navigation always goes through the interactive client-side router. This point is only relevant in an uncommon scenario where an interactive <Router> is nested inside a server-side rendered <Router>. In that case, the interactive router takes priority when handling navigation, so enhanced navigation isn't used for navigation because it's a server-side routing feature.

WRT the change for these, I'll need to look closer on Monday. I see now that given the confusion over the statement that it might be best to strike that entire paragraph. If you have a replacement, let me know.

I think that's the direction that @MackinnonBuck was leaning the first place.

I'll confirm/add/update to make sure that your, @javiercn, "TL;DR" explanation is present.

UPDATE

@MackinnonBuck ... Let's discuss further on the PR that I opened (dotnet/AspNetCore.Docs#31118).

@Nephim
Copy link
Author

Nephim commented Nov 25, 2023

My desired behavior?

It sounds to me that, my desired behavior would be possible, by having a router component in web-assembly. Which would take over when the page has fully loaded and avoid the current issues with the Counter Component getting "reset", is this correctly understood?

Or maybe it would be as simple as setting a @key on the component?

I'll try out the possible solutions Monday.

Regarding the docs

From my perspective, if you mention something like this:

If enhanced navigation is available for interactive client routing, navigation always goes through the interactive client-side router. This point is only relevant in an uncommon scenario where an interactive is nested inside a server-side rendered . In that case, the interactive router takes priority when handling navigation, so enhanced navigation isn't used for navigation because it's a server-side routing feature.

I feel like there should probably be a link describing or showing what this does and why it might be useful?

Also an explanation about how you can avoid having your component reset would be very valuable. At least to me but I may be biased, and maybe there is a way explained somewhere in the docs but I have been unable to find any mention of it, @key docs might be how you would do it but from reading that doc its hard to see and hard to search as navigation or SSR/Webassembly isn't mentioned.

@Nephim
Copy link
Author

Nephim commented Nov 27, 2023

I have now tested both possible solutions, however none of them seem to be working for me. It could be that I'm doing something wrong, so I'll showcase a Minimal project that I've been testing with, I have put it in a public GitHub. Its the Template project from VS with SSR + InteractiveAuto.

@key

<PageTitle>Counter</PageTitle>
<div @key=1>
    @if (Count is not null)
    {
        <ComponentDetails Count="@Count" />
    }
    else
    {
        <h1>Counter</h1>

        <p role="status">Current count: @currentCount</p>

        <button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

        <button @onclick=@(() => NavigationManager.NavigateTo($"/wasm/counter/{currentCount}"))>SubPage</button>
    }
</div>


@code {
    [Parameter] public int? Count { get; set; }
    [Inject] private NavigationManager NavigationManager { get; set; } = default!;
    protected override void OnAfterRender(bool firstRender)
    {
        if(firstRender)
        {
            Console.WriteLine("First render!");
        }
    }

    private int currentCount = 0;

    private void IncrementCount()
    {
        currentCount++;
    }
}

Wasm Router

WsamRouter.razor

@page "/wasm/*"
@rendermode @(new InteractiveAutoRenderMode(prerender: false))

<Router AppAssembly="@typeof(Program).Assembly">
    <Found Context="routeData">
        <RouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.WasmLayout)" />
        <FocusOnNavigate RouteData="@routeData" Selector="h1" />
    </Found>
</Router>


@code {
    protected override void OnInitialized()
    {
        base.OnInitialized();

        Console.WriteLine("Using WASM router");
    }
}

Counter.razor

@page "/wasm/counter/{Count:int?}"
@rendermode InteractiveAuto

<PageTitle>Counter</PageTitle>
<div @key=1>
    @if (Count is not null)
    {
        <ComponentDetails Count="@Count" />
    }
    else
    {
        <h1>Counter</h1>

        <p role="status">Current count: @currentCount</p>

        <button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

        <button @onclick=@(() => NavigationManager.NavigateTo($"/wasm/counter/{currentCount}"))>SubPage</button>
    }
</div>


@code {
    [Parameter] public int? Count { get; set; }
    [Inject] private NavigationManager NavigationManager { get; set; } = default!;
    protected override void OnAfterRender(bool firstRender)
    {
        if(firstRender)
        {
            Console.WriteLine("First render!");
        }
    }

    private int currentCount = 0;

    private void IncrementCount()
    {
        currentCount++;
    }
}

Please let me know if there is anything else I should test

@Nephim
Copy link
Author

Nephim commented Nov 28, 2023

I have figured out a solution that works for me. It feels a little hack'y, but seems the best solution I've found so far. I'm basically using a nested router to intercept navigation events and keep them in Interactive Auto Render mode.

Wasm Router

@page "/counter/{Count:int?}"
@rendermode InteractiveAuto

<Router AppAssembly="@typeof(Program).Assembly">
    <Found Context="routeData">
        @{
            routeData.RouteValues.TryGetValue("Count", out var count);
        }
        <Counter Count=@((int?)count) />
    </Found>
</Router>

@code {
    [Parameter] public int? Count { get; set; }
    protected override void OnInitialized()
    {
        base.OnInitialized();

        Console.WriteLine("Using WASM router");
    }

    protected override void OnAfterRender(bool firstRender)
    {
        Console.WriteLine("Render in Router");
    }
}

Wasm Router

@rendermode InteractiveAuto

<PageTitle>Counter</PageTitle>

@if (Count is not null)
{
    <ComponentDetails Count="@Count" />
}
else
{
    <h1>Counter</h1>

    <p role="status">Current count: @currentCount</p>

    <button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

    <button @onclick=@(() => NavigationManager.NavigateTo($"/counter/{currentCount}"))>SubPage</button>
}


@code {
    [Parameter] public int? Count { get; set; }
    [Inject] private NavigationManager NavigationManager { get; set; } = default!;


    protected override void OnAfterRender(bool firstRender)
    {
        Console.WriteLine("Render");

        if(firstRender)
        {
            Console.WriteLine("First render!");
        }
    }

    private int currentCount = 0;

    private void IncrementCount()
    {
        currentCount++;
    }
}

The Counter page now only gets SSR loaded once and then WASM/Server takes over, keeping the state. Maybe you find my solution interesting. Please let me know if you have any questions. I've put the complete solution on GitHub.

Edit...

I've discovered an issue with my approach basically the router would be unable to navigate away from the internal Counter component when it had first been rendered. I have solved this in a not so pretty way.

Wasm Router

@page "/counter/{Count:int?}"
@rendermode InteractiveAuto

<Router OnNavigateAsync="Navigation" AppAssembly="@typeof(Program).Assembly">
    <Found Context="routeData">
        @{
            routeData.RouteValues.TryGetValue("Count", out var count);
        }
        <Counter Count=@((int?)count) />
    </Found>
</Router>

@code {
    [Parameter] public int? Count { get; set; }
    [Inject] NavigationManager NavigationManager { get; set; } = default!;

    private void Navigation(NavigationContext context)
    {
        if (!context.Path.StartsWith("counter", StringComparison.InvariantCultureIgnoreCase))
        {
            NavigationManager.NavigateTo(context.Path, true);
        }
    }

    protected override void OnInitialized()
    {
        base.OnInitialized();

        Console.WriteLine("Using WASM router");
    }

    protected override void OnAfterRender(bool firstRender)
    {
        Console.WriteLine("Render in Router");
    }
}

Do you know a better way of achieving this? Essentially the router should propagate the navigation if the page Isn't "counter". Instead of just reloading the page.

@MackinnonBuck
Copy link
Member

@Nephim another way you can do this is by moving most of the page's functionality into a non-page component, making the page non-interactive, and specifying an interactive render mode for the factored-out component. For example:

CounterPage.razor

@page "/counter/{count:int}"
@inject NavigationManager NavigationManager

<PageTitle>Counter</PageTitle>

<h1>Counter</h1>

<Counter @key="0" @rendermode="InteractiveServer" Count="Count" />

@code {
    [Parameter] public int Count { get; set; }
}

This example specifies a constant value for @key so the component gets preserved between renders.

Does that approach work for you?

@Nephim
Copy link
Author

Nephim commented Nov 28, 2023

@MackinnonBuck I have just tested this on the example solution and looks like it solves my Issue, and It's a very clean and simple solution to boot. I'll test it with a more expansive solution tomorrow but I expect it should work perfectly in my case.

From my point of view this information is very useful and I have not been able to find it, anywhere else. So maybe you should consider updating the docs with a paragraf mentioning this. Maybe as a replacement for this commit.

It could say something like:

A component's with a page directive will have its state reset on navigation if the application uses an SSR router. This can be avoided by having a Non-Interactive component wrap an Interactive component that has the ["@key" attribute](https://learn.microsoft.com/en-us/aspnet/core/blazor/components/element-component-model-relationships?view=aspnetcore-8.0). This tells Blazor to keep the component and re-render it with new url parameters.

Maybe with a code snip like this:

@page "/counter/{count:int?}"

<PageTitle>Counter</PageTitle>

<Counter @key="0" @rendermode="InteractiveServer" Count="Count" />

@code {
    [Parameter] public int? Count { get; set; }
}

I'll report back if it worked for me, please let me know if there I can help with something. 😄

Update

It also works on the expansive solution and it does what I want and acts the way I would expect. So everything looking good now 👍

@guardrex
Copy link
Contributor

@Nephim ... We've decided to hold off on it for the moment, but we have the content ready to include. I'm going to add a note to one of my tracking issues and chat further with Mackinnon about it in 24Q1.

@ghost
Copy link

ghost commented Jan 28, 2024

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 Jan 28, 2024
@github-actions github-actions bot locked and limited conversation to collaborators Apr 23, 2024
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