Skip to content

Conversation

@BekAllaev
Copy link
Contributor

@BekAllaev BekAllaev commented Jan 25, 2026

What kind of change does this PR introduce?

Adding Blazor test app as request in #2450

What is the current behavior?

What is the new behavior?

What might this PR break?

Please check if the PR fulfills these requirements

  • Tests for the changes have been added (for bug fixes / features)
  • Docs have been added / updated (for bug fixes / features)

Other information:
I took WpfTest app as a sample and mostly used the code from it. Changes that were made was to adopt code from WpfTest to Blazor specifics. Here is demonstration of the test app

Demonstration.mp4

@BekAllaev BekAllaev changed the title Add blazor test app #2450 Add blazor test app Jan 25, 2026
@BekAllaev
Copy link
Contributor Author

Hello @glennawatson.
I am not sure if I have done NuGet packages correct. In my Blazor test app I directly added NuGet packages to Blazor project. Like my NuGet section looks like this now
image

I am not sure if it is right

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new Blazor Server “test app” project to the repo to demonstrate ReactiveUI usage (chat/lobby sample), including UI, routing, state, and some cross-instance syncing plumbing.

Changes:

  • Added ReactiveUI.Builder.Blazor project and wired it into the solution.
  • Implemented Blazor UI + ReactiveUI view models for lobby/rooms and chat room messaging.
  • Added bundled static assets (Bootstrap) and app scaffolding/configuration.

Reviewed changes

Copilot reviewed 41 out of 85 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
src/reactiveui.slnx Adds the Blazor test app project to the solution.
src/ReactiveUI.Builder.Blazor/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js Vendored Bootstrap JS for the Blazor UI.
src/ReactiveUI.Builder.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css Vendored Bootstrap CSS (reboot RTL min).
src/ReactiveUI.Builder.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css Vendored Bootstrap CSS (reboot RTL).
src/ReactiveUI.Builder.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css Vendored Bootstrap CSS (reboot min).
src/ReactiveUI.Builder.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css Vendored Bootstrap CSS (reboot).
src/ReactiveUI.Builder.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css Vendored Bootstrap CSS (grid RTL min).
src/ReactiveUI.Builder.Blazor/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css Vendored Bootstrap CSS (grid min).
src/ReactiveUI.Builder.Blazor/wwwroot/favicon.png Adds favicon for the Blazor app.
src/ReactiveUI.Builder.Blazor/wwwroot/app.css Adds app-specific CSS.
src/ReactiveUI.Builder.Blazor/appsettings.json Adds production app settings.
src/ReactiveUI.Builder.Blazor/appsettings.Development.json Adds development app settings.
src/ReactiveUI.Builder.Blazor/Views/LobbyView.razor.cs Lobby view event handlers (create/delete/join room).
src/ReactiveUI.Builder.Blazor/Views/LobbyView.razor Lobby UI markup.
src/ReactiveUI.Builder.Blazor/Views/ChatRoomView.razor.cs Chat room view event handlers (send/back).
src/ReactiveUI.Builder.Blazor/Views/ChatRoomView.razor Chat room UI markup.
src/ReactiveUI.Builder.Blazor/ViewModels/LobbyViewModel.cs Lobby view model: room list, create/delete, sync.
src/ReactiveUI.Builder.Blazor/ViewModels/ChatRoomViewModel.cs Chat room view model: messages and send logic.
src/ReactiveUI.Builder.Blazor/ViewModels/AppBootstrapper.cs Sets up per-circuit routing bootstrapper.
src/ReactiveUI.Builder.Blazor/Services/RoomEventKind.cs Defines room sync event types.
src/ReactiveUI.Builder.Blazor/Services/ReactiveUIAppHostedService.cs Initializes app state & lifetime coordination.
src/ReactiveUI.Builder.Blazor/Services/FileJsonSuspensionDriver.cs Adds a JSON suspension driver for persisted state.
src/ReactiveUI.Builder.Blazor/Services/AppLifetimeCoordinator.cs Adds cross-process instance counter helper.
src/ReactiveUI.Builder.Blazor/Services/AppInstance.cs Adds a unique app-instance ID holder.
src/ReactiveUI.Builder.Blazor/ReactiveUI.Builder.Blazor.csproj Adds the new Blazor project definition.
src/ReactiveUI.Builder.Blazor/Properties/launchSettings.json Adds launch profiles for the Blazor app.
src/ReactiveUI.Builder.Blazor/Program.cs Configures DI, ReactiveUI builder, and Blazor server pipeline.
src/ReactiveUI.Builder.Blazor/Models/RoomEventMessage.cs Defines room event/snapshot message payload.
src/ReactiveUI.Builder.Blazor/Models/ChatStateChanged.cs Adds message type for local state refresh.
src/ReactiveUI.Builder.Blazor/Models/ChatState.cs Defines persisted chat state model.
src/ReactiveUI.Builder.Blazor/Models/ChatRoom.cs Defines chat room model.
src/ReactiveUI.Builder.Blazor/Models/ChatNetworkMessage.cs Defines network message model.
src/ReactiveUI.Builder.Blazor/Models/ChatMessage.cs Defines single chat message model.
src/ReactiveUI.Builder.Blazor/Components/_Imports.razor Adds Razor imports for the app.
src/ReactiveUI.Builder.Blazor/Components/Routes.razor Adds route configuration.
src/ReactiveUI.Builder.Blazor/Components/Pages/NotFound.razor Adds NotFound page.
src/ReactiveUI.Builder.Blazor/Components/Pages/Error.razor Adds Error page.
src/ReactiveUI.Builder.Blazor/Components/Layout/ReconnectModal.razor.js Adds reconnect modal JS behavior.
src/ReactiveUI.Builder.Blazor/Components/Layout/ReconnectModal.razor.css Adds reconnect modal styling.
src/ReactiveUI.Builder.Blazor/Components/Layout/ReconnectModal.razor Adds reconnect modal markup.
src/ReactiveUI.Builder.Blazor/Components/Layout/ReactiveRouterHost.razor Adds a router host that resolves views from view models.
src/ReactiveUI.Builder.Blazor/Components/Layout/NavMenu.razor.css Adds nav menu styling.
src/ReactiveUI.Builder.Blazor/Components/Layout/NavMenu.razor Adds nav menu markup.
src/ReactiveUI.Builder.Blazor/Components/Layout/MainLayout.razor.css Adds main layout styling.
src/ReactiveUI.Builder.Blazor/Components/Layout/MainLayout.razor.cs Wires ReactiveUI ViewModel into layout.
src/ReactiveUI.Builder.Blazor/Components/Layout/MainLayout.razor Adds main layout markup.
src/ReactiveUI.Builder.Blazor/Components/App.razor Adds HTML host + asset references for the app.
src/Directory.Packages.props Adds package versions for Blazor dependencies and changes Splat version.
Comments suppressed due to low confidence (5)

src/ReactiveUI.Builder.Blazor/ViewModels/ChatRoomViewModel.cs:1

  • ToProperty is binding the bool observable (canSend) to the SendMessage command property name, which is a type mismatch and will fail at runtime. Bind the OAPH to a boolean property (e.g., CanSend) and have CanSendDisabled derive from it (or bind directly to CanSendDisabled with the correct semantics).
    src/ReactiveUI.Builder.Blazor/ViewModels/ChatRoomViewModel.cs:1
  • ToProperty is binding the bool observable (canSend) to the SendMessage command property name, which is a type mismatch and will fail at runtime. Bind the OAPH to a boolean property (e.g., CanSend) and have CanSendDisabled derive from it (or bind directly to CanSendDisabled with the correct semantics).
    src/ReactiveUI.Builder.Blazor/Views/LobbyView.razor.cs:1
  • OnRoomClicked is marked async but doesn’t await anything. Remove async and return Task.CompletedTask, or change the handler to a synchronous method to avoid compiler warnings and reduce overhead.
    src/ReactiveUI.Builder.Blazor/ViewModels/ChatRoomViewModel.cs:1
  • The comment says the message is posted on a “null contract”, but the code sends with RoomName as the contract. Update the comment to match the behavior (or change the code if the intent was truly a null/empty contract).
    src/ReactiveUI.Builder.Blazor/Views/LobbyView.razor:1
  • The UI mixes English labels with a Russian label (Название:). For consistency (and easier future localization), consider keeping labels in one language or moving strings into a localization/resource mechanism.

Comment on lines +34 to +47
&[open]

{
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
animation-fill-mode: both;
}

}

#components-reconnect-modal::backdrop {
background-color: rgba(0, 0, 0, 0.4);
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
opacity: 1;
}
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The selector nesting (&[open]) and brace placement here is not valid in plain CSS and is likely to be ignored, breaking the “open” dialog animation. Use a standard selector like #components-reconnect-modal[open] { ... } instead of nested &[open].

Suggested change
&[open]
{
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
animation-fill-mode: both;
}
}
#components-reconnect-modal::backdrop {
background-color: rgba(0, 0, 0, 0.4);
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
opacity: 1;
}
}
#components-reconnect-modal[open] {
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
animation-fill-mode: both;
}
#components-reconnect-modal::backdrop {
background-color: rgba(0, 0, 0, 0.4);
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
opacity: 1;
}
background-color: rgba(0, 0, 0, 0.4);
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
opacity: 1;
}

Copilot uses AI. Check for mistakes.
Comment on lines 29 to 39
public async Task StartAsync(CancellationToken cancellationToken)
{
RxApp.SuspensionHost.CreateNewAppState = static () => new ChatState();

// Set an initial state instantly (same idea as WPF to avoid blocking)
RxApp.SuspensionHost.AppState = new ChatState();

var lifetime = Locator.Current.GetService<AppLifetimeCoordinator>();
var count = lifetime?.Increment() ?? 1;
Trace.WriteLine($"[Blazor] Instance started. Count={count} Id={AppInstance.Id}");
}
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StartAsync/StopAsync are marked async but have no awaits, which will produce compiler warnings and unnecessary state machines. Prefer returning Task.CompletedTask (and remove async) or actually await any async work (and consider using cancellationToken where applicable).

Copilot uses AI. Check for mistakes.
Comment on lines +50 to +55
public async Task StopAsync(CancellationToken cancellationToken)
{
var lifetime = Locator.Current.GetService<AppLifetimeCoordinator>();
var remaining = lifetime?.Decrement() ?? 0;
Trace.WriteLine($"[Blazor] Instance exiting. Remaining={remaining} Id={AppInstance.Id}");
}
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StartAsync/StopAsync are marked async but have no awaits, which will produce compiler warnings and unnecessary state machines. Prefer returning Task.CompletedTask (and remove async) or actually await any async work (and consider using cancellationToken where applicable).

Copilot uses AI. Check for mistakes.
</PropertyGroup>
<PropertyGroup>
<SplatVersion>19.2.1</SplatVersion>
<SplatVersion>17.1.1</SplatVersion>
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR globally downgrades SplatVersion from 19.2.1 to 17.1.1 for the entire repo, which can unintentionally affect unrelated projects and dependency graphs. Prefer keeping the existing repo-wide Splat version and (if needed) overriding only for the Blazor app (e.g., via project-level PackageReference version or a dedicated property for the sample).

Suggested change
<SplatVersion>17.1.1</SplatVersion>
<SplatVersion>19.2.1</SplatVersion>

Copilot uses AI. Check for mistakes.
Comment on lines 31 to 32
<PackageVersion Include="ReactiveUI.Blazor" Version="22.3.1" />
<PackageVersion Include="Splat.Microsoft.Extensions.DependencyInjection" Version="$(SplatVersion)" />
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR globally downgrades SplatVersion from 19.2.1 to 17.1.1 for the entire repo, which can unintentionally affect unrelated projects and dependency graphs. Prefer keeping the existing repo-wide Splat version and (if needed) overriding only for the Blazor app (e.g., via project-level PackageReference version or a dedicated property for the sample).

Copilot uses AI. Check for mistakes.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for full license information.

namespace ReactiveUI.Builder.Blazor.Services;
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file is under Models/ but declares the ReactiveUI.Builder.Blazor.Services namespace. This mismatch makes the codebase harder to navigate (and is easy to misread when searching). Consider moving the file into the Services/ folder or changing the namespace to ReactiveUI.Builder.Blazor.Models (and updating references accordingly).

Suggested change
namespace ReactiveUI.Builder.Blazor.Services;
namespace ReactiveUI.Builder.Blazor.Models;

Copilot uses AI. Check for mistakes.
@ChrisPulman
Copy link
Member

@BekAllaev Thank you for this, please could you rebase this onto the latest main code.

You will need to update a few things once you do this.

  • Place your Blazor Chat App into the src/examples folder
  • Remove the Nuget ReactiveUI.Blazor package and replace with a project reference to ReactiveUI.Blazor.csproj
  • Copy the FileJsonSuspensionDriver from the current Wpf Chat App
  • Update the RxApp references to use RxSchedulers.MainThreadScheduler, RxSchedulers.TaskpoolScheduler, and RxSuspension.SuspensionHost
  • Update the ReactiveUiAppHostedService class to match the following so that both the Wpf and Blazor versions share the stored state:
public sealed class ReactiveUiAppHostedService : IHostedService
{
    private FileJsonSuspensionDriver? _driver;

    /// <summary>
    /// Initializes the application state and starts required services asynchronously.
    /// </summary>
    /// <remarks>This method loads any previously persisted application state and notifies listeners if the
    /// state changes. It also starts network and lifetime coordination services required for the application's
    /// operation. If loading the persisted state fails, the application continues with a new state instance.
    /// </remarks>
    /// <param name="cancellationToken">A cancellation token that can be used to cancel the asynchronous operation.</param>
    /// <returns>A task that represents the asynchronous start operation.</returns>
    public async Task StartAsync(CancellationToken cancellationToken)
    {
        RxSuspension.SuspensionHost.CreateNewAppState = static () => new ChatState();

        // Set an initial state instantly (same idea as WPF to avoid blocking)
        RxSuspension.SuspensionHost.AppState = new ChatState();

        var statePath = Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
            "ReactiveUI.Builder.WpfApp",
            "state.json");
        Directory.CreateDirectory(Path.GetDirectoryName(statePath)!);

        _driver = new FileJsonSuspensionDriver(statePath);

        // Set an initial state instantly to avoid blocking UI
        RxSuspension.SuspensionHost.AppState = new ChatState();

        // Load persisted state asynchronously and update UI when ready
        _ = _driver
            .LoadState()
            .ObserveOn(RxSchedulers.MainThreadScheduler)
            .Subscribe(
                static stateObj =>
                {
                    RxSuspension.SuspensionHost.AppState = stateObj;
                    MessageBus.Current.SendMessage(new ChatStateChanged());
                    Trace.WriteLine("[App] State loaded");
                },
                static ex => Trace.WriteLine($"[App] State load failed: {ex.Message}"));

        var lifetime = Locator.Current.GetService<AppLifetimeCoordinator>();
        var count = lifetime?.Increment() ?? 1;
        Trace.WriteLine($"[Blazor] Instance started. Count={count} Id={AppInstance.Id}");
    }

    /// <summary>
    /// Performs application shutdown tasks asynchronously, including saving application state and releasing network
    /// resources.
    /// </summary>
    /// <remarks>If this is the last running instance, the method saves the current application state before
    /// disposing of network resources. Subsequent calls after all instances have exited will not trigger additional
    /// state saves.</remarks>
    /// <param name="cancellationToken">A cancellation token that can be used to cancel the shutdown operation.</param>
    /// <returns>A task that represents the asynchronous shutdown operation.</returns>
    public async Task StopAsync(CancellationToken cancellationToken)
    {
        var lifetime = Locator.Current.GetService<AppLifetimeCoordinator>();
        var remaining = lifetime?.Decrement() ?? 0;
        Trace.WriteLine($"[Blazor] Instance exiting. Remaining={remaining} Id={AppInstance.Id}");

        // Only the last instance persists the final state to the central store
        if (remaining == 0 && _driver is not null && RxSuspension.SuspensionHost.AppState is not null)
        {
            _driver.SaveState(RxSuspension.SuspensionHost.AppState).Wait();
        }
    }
}

I believe at this point the application should build and run as expected

ChrisPulman and others added 12 commits January 26, 2026 13:05
…activeui#4278)

<!-- Please be sure to read the
[Contribute](https://github.com/reactiveui/reactiveui#contribute)
section of the README -->

**What kind of change does this PR introduce?**
<!-- Bug fix, feature, docs update, ... -->

Update

**What is the new behavior?**
<!-- If this is a feature change -->

Simplifies command parameter subscription and usage by removing
redundant local variables and consistently using 'latestParameter'.

Updates CompositeDisposable initialization and improves code clarity in
command binding logic.

**What might this PR break?**

None expected

**Please check if the PR fulfills these requirements**
- [ ] Tests for the changes have been added (for bug fixes / features)
- [ ] Docs have been added / updated (for bug fixes / features)

**Other information**:
Changed the chat room name label from Russian ("Название:") to English ("Room name:") for consistency in the LobbyView.razor file.
Upgraded Splat to v19.2.1 in central package props. Removed ReactiveUI.Blazor NuGet package references and replaced with a direct project reference in ReactiveUI.Builder.Blazor. No other package versions or settings changed.
Refactored LoadState and SaveState methods in FileJsonSuspensionDriver to support generic types and JsonTypeInfo for serialization. Replaced hardcoded ChatState logic with generic overloads (currently not implemented). Updated scheduler to RxSchedulers.TaskpoolScheduler and improved XML documentation.
Refactored RoomEventMessage from Services to Models namespace.
Updated all references and using directives accordingly for better
code organization. No changes to logic or functionality.
Removed an outdated comment from SendMessageImpl in ChatRoomViewModel.cs. Changed OnRoomClicked in LobbyView.razor.cs from async Task to synchronous void to better reflect its usage. No functional changes to message sending logic.
Replaced RxApp and RxApp.SuspensionHost with RxSuspension equivalents for app state management. Updated scheduler references to RxSchedulers.TaskpoolScheduler and RxSchedulers.MainThreadScheduler for consistency with newer ReactiveUI APIs. No changes to application logic.
@BekAllaev
Copy link
Contributor Author

BekAllaev commented Jan 26, 2026

@ChrisPulman, I broke my commit history while rebasing. Can I close this PR and open new one?

I will backup my current working version, then I am going to create new branch and move my working version there. It is easier and faster for me to do it that way

@glennawatson
Copy link
Contributor

Sure re-create the PR no problem, if you don't mind add the prefix "feature: Add blazor example app" as the title for the next PR.

@glennawatson
Copy link
Contributor

The other option btw, with your other branch, is just to do git push add-blazor-test-app --force and that will update the current PR as well and remove all the commits.

@glennawatson
Copy link
Contributor

Also:

To correct my last comment.

If you go that route, I’d recommend using:

git push --force-with-lease origin add-blazor-test-app

This updates the existing PR just like --force, but with a safety check so you don’t accidentally overwrite any newer commits on the branch.

The PR will refresh to the new commit set and CI will re-run as usual (once we give approval).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants