Back to server for load speed

This commit is contained in:
2026-09-09 13:48:58 -04:00
parent 5ce3559c74
commit 08943fe6f4
15 changed files with 514 additions and 81 deletions
+151
View File
@@ -0,0 +1,151 @@
---
sessionId: session-260909-133404-1um1
---
# Requirements
### Overview & Goals
Convert the `Web` project from a standalone Blazor WebAssembly application back to an ASP.NET Core Blazor Server application with interactive server components.
### Scope
#### In Scope
- Update `Web/Web.csproj` to use `Microsoft.NET.Sdk.Web` and remove WebAssembly client dependencies.
- Convert `Web/Program.cs` from `WebAssemblyHostBuilder` to ASP.NET Core `WebApplication.CreateBuilder` with Razor Components and interactive server render modes.
- Relocate configuration files (`appsettings.json`, `appsettings.Development.json`) from `Web/wwwroot/` to the project root `Web/`.
- Remove `Web/wwwroot/index.html` and restore `Web/Components/App.razor` as the HTML host root.
- Add `Web/Components/Routes.razor`, `Web/Components/Pages/Error.razor`, and `Web/Components/Layout/ReconnectModal.razor` (with corresponding CSS and JS).
- Update `Web/Components/_Imports.razor` with server rendering imports.
#### Out of Scope
- Modifying resume content data files (`Overview.cs`, `PersonalInfo.cs`, `Skills.cs`, `WorkExperience.cs`).
- Modifying resume UI layouts or styles in `Home.razor`, `MainLayout.razor`, or `app.css`.
### User Stories
- As a visitor, I want to load the resume application rendered quickly from the ASP.NET Core server with interactive server capabilities.
- As a developer, I want the project configured as a standard ASP.NET Core Blazor Server app so that it can leverage server-side capabilities and runtime services.
### Functional Requirements
- The application must start using ASP.NET Core `WebApplication` hosting.
- Navigation to `/` must render the resume page (`Home.razor`) inside `MainLayout.razor`.
- Navigation to unknown routes must display the custom not-found page (`NotFound.razor`) via `UseStatusCodePagesWithReExecute("/not-found")` and the Blazor router.
- Disconnected or reconnecting Blazor Server circuits must display the interactive reconnection modal dialog.
- Server-side error routing must be handled via `/Error` rendering `Error.razor`.
### Non-Functional Requirements
- Maintain compatibility with .NET 10 (`net10.0`).
- Ensure clean build with zero compile errors.
# Technical Design
### Current Implementation
The `Web` project is currently configured as a standalone Blazor WebAssembly client using `Microsoft.NET.Sdk.BlazorWebAssembly`, with `Program.cs` invoking `WebAssemblyHostBuilder.CreateDefault(args)`, static hosting through `Web/wwwroot/index.html`, and client configuration files in `Web/wwwroot/`.
### Key Decisions
- **Hosting Model**: Use ASP.NET Core Blazor Server with interactive server components (`AddInteractiveServerComponents()` and `.AddInteractiveServerRenderMode()`).
- **Host Document**: Use `Web/Components/App.razor` as the HTML root document containing head elements, styles, scripts, reconnect modal, and `<Routes />`.
- **Configuration Placement**: Move `appsettings.json` and `appsettings.Development.json` to the `Web/` project root, matching standard ASP.NET Core hosting conventions.
### Proposed Changes
1. **`Web/Web.csproj`**:
- Change SDK to `Microsoft.NET.Sdk.Web`.
- Remove `<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" ... />` and `<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" ... />`.
- Set `<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>`.
2. **`Web/Program.cs`**:
- Build server host via `var builder = WebApplication.CreateBuilder(args);`.
- Register `builder.Services.AddRazorComponents().AddInteractiveServerComponents();`.
- Configure HTTP request pipeline: exception handler `/Error`, HSTS, status code re-execute `/not-found`, HTTPS redirection, antiforgery, static assets mapping (`MapStaticAssets()`), and `MapRazorComponents<App>().AddInteractiveServerRenderMode()`.
3. **`Web/wwwroot/`**:
- Remove `index.html`.
- Move `appsettings.json` and `appsettings.Development.json` up to `Web/`.
4. **`Web/Components/`**:
- `App.razor`: HTML host markup with `<Routes />`, `<ReconnectModal />`, `<script src="@Assets["_framework/blazor.web.js"]"></script>`.
- `Routes.razor`: `<Router>` definition referencing `Program.Assembly` and `NotFoundPage="typeof(Pages.NotFound)"`.
- `Layout/ReconnectModal.razor` (+ `.razor.css`, `.razor.js`): Circuit reconnection overlay.
- `Pages/Error.razor`: Error display component.
- `_Imports.razor`: Add `@using static Microsoft.AspNetCore.Components.Web.RenderMode` and remove WebAssembly-only namespaces.
### File Structure
```
Web/
├── appsettings.json
├── appsettings.Development.json
├── Program.cs
├── Web.csproj
├── Properties/
│ └── launchSettings.json
├── Components/
│ ├── _Imports.razor
│ ├── App.razor
│ ├── Routes.razor
│ ├── Layout/
│ │ ├── MainLayout.razor
│ │ ├── MainLayout.razor.css
│ │ ├── ReconnectModal.razor
│ │ ├── ReconnectModal.razor.css
│ │ └── ReconnectModal.razor.js
│ └── Pages/
│ ├── Error.razor
│ ├── Home.razor
│ ├── Home.razor.css
│ └── NotFound.razor
├── Data/
│ ├── Overview.cs
│ ├── PersonalInfo.cs
│ ├── Skills.cs
│ └── WorkExperience.cs
└── wwwroot/
├── app.css
├── favicon.png
└── lib/bootstrap/...
```
### Architecture Diagram
```mermaid
graph TD
Browser[Web Browser] -->|HTTP / WebSocket SignalR| ASPNET[ASP.NET Core Server]
ASPNET --> Pipeline[Middleware Pipeline]
Pipeline --> MapStatic[MapStaticAssets]
Pipeline --> RazorComponents[MapRazorComponents: App.razor]
RazorComponents --> Routes[Routes.razor / Router]
Routes --> MainLayout[MainLayout.razor]
MainLayout --> Home[Home.razor]
MainLayout --> NotFound[NotFound.razor]
Home --> Data[Data Layer: Overview, PersonalInfo, Skills, WorkExperience]
```
# Testing
### Validation Approach
- Verify successful compilation with `dotnet build Web/Web.csproj`.
- Validate that no obsolete WebAssembly packages or unused `index.html` remain.
- Validate that all server components, router, reconnect modal, error handling, and static asset mappings compile and link properly.
### Key Scenarios
- **Build Verification**: Ensure `dotnet build Web/Web.csproj` completes with 0 errors.
- **Root Page Loading**: Ensure root route `/` routes to `Home.razor` within `MainLayout.razor`.
- **404 Handling**: Ensure unknown routes re-execute to `/not-found` rendering `NotFound.razor`.
- **Error Page**: Ensure `/Error` is routable to `Error.razor`.
### Edge Cases
- Disconnected SignalR circuit: Reconnection modal displays appropriate rejoining/paused/failed statuses.
- Static asset path resolution: Verify `_framework/blazor.web.js`, Bootstrap CSS, and `app.css` resolve with `@Assets`.
# Delivery Steps
### ✓ Step 1: Convert project SDK, configuration files, and ASP.NET Core server pipeline
The Web project is configured as an ASP.NET Core Web application with server hosting services and pipeline middleware.
- Update `Web/Web.csproj` SDK to `Microsoft.NET.Sdk.Web`, remove `Microsoft.AspNetCore.Components.WebAssembly` package dependencies, and set `BlazorDisableThrowNavigationException`.
- Relocate `appsettings.json` and `appsettings.Development.json` from `Web/wwwroot/` to the project root directory `Web/`.
- Remove the standalone static `Web/wwwroot/index.html` host file.
- Rewrite `Web/Program.cs` to initialize `WebApplication.CreateBuilder(args)`, configure Razor components with interactive server support (`AddRazorComponents().AddInteractiveServerComponents()`), and set up HTTP middleware (antiforgery, static asset mapping, status code re-execution, exception handling, and `MapRazorComponents<App>().AddInteractiveServerRenderMode()`).
### ✓ Step 2: Restore Blazor Server host, routing, reconnect UX, and imports
The application renders HTML from the server with full routing, error handling, and circuit reconnection UI.
- Update `Web/Components/App.razor` to serve as the HTML root document with `<Routes />`, `<ReconnectModal />`, `<HeadOutlet />`, stylesheet assets, and `_framework/blazor.web.js`.
- Add `Web/Components/Routes.razor` defining the `<Router>` with `AppAssembly="typeof(Program).Assembly"` and `NotFoundPage="typeof(Pages.NotFound)"`.
- Add `Web/Components/Layout/ReconnectModal.razor` and its scoped CSS / JS assets (`ReconnectModal.razor.css`, `ReconnectModal.razor.js`) to handle Blazor Server circuit reconnection states.
- Add `Web/Components/Pages/Error.razor` to handle server-side request errors and show request diagnostic IDs.
- Update `Web/Components/_Imports.razor` to import static render modes and server component namespaces.
- Verify the solution builds without errors and serves the resume application over Blazor Server.
+23 -12
View File
@@ -1,12 +1,23 @@
<Router AppAssembly="@typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(Layout.MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<ResourcePreloader />
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["Web.styles.css"]" />
<ImportMap />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet />
</head>
<body>
<Routes />
<ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script>
</body>
</html>
@@ -0,0 +1,31 @@
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
<dialog id="components-reconnect-modal" data-nosnippet>
<div class="components-reconnect-container">
<div class="components-rejoining-animation" aria-hidden="true">
<div></div>
<div></div>
</div>
<p class="components-reconnect-first-attempt-visible">
Rejoining the server...
</p>
<p class="components-reconnect-repeated-attempt-visible">
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
</p>
<p class="components-reconnect-failed-visible">
Failed to rejoin.<br />Please retry or reload the page.
</p>
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
Retry
</button>
<p class="components-pause-visible">
The session has been paused by the server.
</p>
<p class="components-resume-failed-visible">
Failed to resume the session.<br />Please retry or reload the page.
</p>
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
Resume
</button>
</div>
</dialog>
@@ -0,0 +1,157 @@
.components-reconnect-first-attempt-visible,
.components-reconnect-repeated-attempt-visible,
.components-reconnect-failed-visible,
.components-pause-visible,
.components-resume-failed-visible,
.components-rejoining-animation {
display: none;
}
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
#components-reconnect-modal.components-reconnect-retrying,
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-failed,
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
display: block;
}
#components-reconnect-modal {
background-color: white;
width: 20rem;
margin: 20vh auto;
padding: 2rem;
border: 0;
border-radius: 0.5rem;
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
opacity: 0;
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
&[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;
}
@keyframes components-reconnect-modal-slideUp {
0% {
transform: translateY(30px) scale(0.95);
}
100% {
transform: translateY(0);
}
}
@keyframes components-reconnect-modal-fadeInOpacity {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes components-reconnect-modal-fadeOutOpacity {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.components-reconnect-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
#components-reconnect-modal p {
margin: 0;
text-align: center;
}
#components-reconnect-modal button {
border: 0;
background-color: #6b9ed2;
color: white;
padding: 4px 24px;
border-radius: 4px;
}
#components-reconnect-modal button:hover {
background-color: #3b6ea2;
}
#components-reconnect-modal button:active {
background-color: #6b9ed2;
}
.components-rejoining-animation {
position: relative;
width: 80px;
height: 80px;
}
.components-rejoining-animation div {
position: absolute;
border: 3px solid #0087ff;
opacity: 1;
border-radius: 50%;
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
}
.components-rejoining-animation div:nth-child(2) {
animation-delay: -0.5s;
}
@keyframes components-rejoining-animation {
0% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
4.9% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
5% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 1;
}
100% {
top: 0px;
left: 0px;
width: 80px;
height: 80px;
opacity: 0;
}
}
@@ -0,0 +1,63 @@
// Set up event handlers
const reconnectModal = document.getElementById("components-reconnect-modal");
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
const retryButton = document.getElementById("components-reconnect-button");
retryButton.addEventListener("click", retry);
const resumeButton = document.getElementById("components-resume-button");
resumeButton.addEventListener("click", resume);
function handleReconnectStateChanged(event) {
if (event.detail.state === "show") {
reconnectModal.showModal();
} else if (event.detail.state === "hide") {
reconnectModal.close();
} else if (event.detail.state === "failed") {
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
} else if (event.detail.state === "rejected") {
location.reload();
}
}
async function retry() {
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
try {
// Reconnect will asynchronously return:
// - true to mean success
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
// - exception to mean we didn't reach the server (this can be sync or async)
const successful = await Blazor.reconnect();
if (!successful) {
// We have been able to reach the server, but the circuit is no longer available.
// We'll reload the page so the user can continue using the app as quickly as possible.
const resumeSuccessful = await Blazor.resumeCircuit();
if (!resumeSuccessful) {
location.reload();
} else {
reconnectModal.close();
}
}
} catch (err) {
// We got an exception, server is currently unavailable
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
}
}
async function resume() {
try {
const successful = await Blazor.resumeCircuit();
if (!successful) {
location.reload();
}
} catch {
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
}
}
async function retryWhenDocumentBecomesVisible() {
if (document.visibilityState === "visible") {
await retry();
}
}
+36
View File
@@ -0,0 +1,36 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}
+6
View File
@@ -0,0 +1,6 @@
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
+2 -2
View File
@@ -1,10 +1,10 @@
@using System.Net.Http
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using Web
@using Web.Components
+1 -1
View File
@@ -91,7 +91,7 @@ public static class WorkExperience
new Point
{
Description =
"Came to the conclusion that I wanted to focus my career on .NET, and left the company to pursue Blazor research and C# opportunities. Unfortunately, Blazor development isn't popular.",
"Came to the conclusion that I wanted to focus my career on .NET, and left the company to pursue Blazor research and C# opportunities. Unfortunately, Blazor development isn't popular, and such specialization makes less sense in the AI era.",
IsVisible = true
}
]
+24 -7
View File
@@ -1,11 +1,28 @@
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Web.Components;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
await builder.Build().RunAsync();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseStatusCodePagesWithReExecute("/not-found");
app.UseHttpsRedirection();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();
+1 -3
View File
@@ -5,7 +5,6 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "http://localhost:5202",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
@@ -15,11 +14,10 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "https://localhost:7263;http://localhost:5202",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
}
+2 -6
View File
@@ -1,15 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Web</RootNamespace>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.6" PrivateAssets="all" />
</ItemGroup>
</Project>
-33
View File
@@ -1,33 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Jonathan McCaffrey - Resume</title>
<base href="/" />
<link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="app.css" />
<link rel="stylesheet" href="Web.styles.css" />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet />
</head>
<body>
<div id="app">
<svg class="loading-progress">
<circle r="40%" cx="50%" cy="50%" />
<circle r="40%" cx="50%" cy="50%" />
</svg>
<div class="loading-progress-text"></div>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>
<script src="_framework/blazor.webassembly.js"></script>
</body>
</html>