# Blazor Server: Session State Management

> **Scope:** blazor-azure
> **Layer:** 2 (on keyword)
> **Keywords:** blazor, state, cascading, parameter, session, circuit
> **Load When:** blazor work

## The Problem

In Blazor Server, `Scoped` services are tied to the **SignalR circuit**, NOT the user session. Circuit recreated on navigation/refresh = **state lost**.

```csharp
// WRONG: Assuming scoped = session
SessionState.CurrentOrderId = orderId;
NavigationManager.NavigateTo("/page2");
// page2: SessionState.CurrentOrderId == null (new circuit)
```

**Solution:** Browser cookie (SessionId) → Middleware → DB lookup → `SessionState.LoadFrom(entity)` on each page init.

---

## Implementation

### SessionState Service

```csharp
public class SessionState
{
    public Guid? CurrentOrderId { get; set; }
    public string? UserPreference { get; set; }
    public Dictionary<string, object> CustomData { get; set; } = new();
    public bool IsLoaded { get; private set; }
    public string? SessionId { get; private set; }

    public void LoadFrom(UserSession entity)
    {
        if (entity == null) return;
        SessionId = entity.SessionId;
        CurrentOrderId = entity.CurrentOrderId;
        UserPreference = entity.UserPreference;
        if (!string.IsNullOrEmpty(entity.CustomDataJson))
            CustomData = JsonSerializer.Deserialize<Dictionary<string, object>>(entity.CustomDataJson) ?? new();
        IsLoaded = true;
    }

    public UserSession ToEntity() => new()
    {
        SessionId = SessionId ?? Guid.NewGuid().ToString(),
        CurrentOrderId = CurrentOrderId,
        UserPreference = UserPreference,
        CustomDataJson = JsonSerializer.Serialize(CustomData),
        LastAccessedAt = DateTime.UtcNow
    };
}
```

### Repository

```csharp
public interface ISessionRepository
{
    Task<UserSession?> GetBySessionIdAsync(string sessionId);
    Task SaveAsync(UserSession session);
    Task DeleteAsync(string sessionId);
    Task CleanupExpiredAsync(TimeSpan maxAge);
}
```

### Session Middleware

```csharp
public class SessionMiddleware(RequestDelegate next)
{
    private const string CookieName = "X-Session-Id";
    public async Task InvokeAsync(HttpContext context)
    {
        var sessionId = context.Request.Cookies[CookieName];
        if (string.IsNullOrEmpty(sessionId))
        {
            sessionId = Guid.NewGuid().ToString();
            context.Response.Cookies.Append(CookieName, sessionId, new CookieOptions
            { HttpOnly = true, Secure = true, SameSite = SameSiteMode.Strict, Expires = DateTimeOffset.UtcNow.AddDays(30) });
        }
        context.Items["SessionId"] = sessionId;
        await next(context);
    }
}
```

### Base Component (DRY Pattern)

```csharp
public abstract class SessionAwareComponent : ComponentBase
{
    [Inject] protected SessionState Session { get; set; } = default!;
    [Inject] protected ISessionRepository SessionRepository { get; set; } = default!;
    [Inject] protected IHttpContextAccessor HttpContextAccessor { get; set; } = default!;
    protected string? SessionId => HttpContextAccessor.HttpContext?.Items["SessionId"] as string;

    protected override async Task OnInitializedAsync()
    {
        await EnsureSessionLoadedAsync();
        await OnSessionInitializedAsync();
    }

    protected async Task EnsureSessionLoadedAsync()
    {
        if (!Session.IsLoaded && !string.IsNullOrEmpty(SessionId))
        {
            var entity = await SessionRepository.GetBySessionIdAsync(SessionId);
            if (entity != null) Session.LoadFrom(entity);
        }
    }

    protected async Task SaveSessionAsync()
    {
        if (string.IsNullOrEmpty(SessionId)) return;
        var entity = Session.ToEntity();
        entity.SessionId = SessionId;
        await SessionRepository.SaveAsync(entity);
    }

    protected virtual Task OnSessionInitializedAsync() => Task.CompletedTask;
}
```

**Usage:** `@inherits SessionAwareComponent` → override `OnSessionInitializedAsync()`.

### Registration

```csharp
// Program.cs
builder.Services.AddScoped<SessionState>();
builder.Services.AddScoped<ISessionRepository, SessionRepository>();
app.UseMiddleware<SessionMiddleware>(); // BEFORE UseRouting
```

---

## Anti-Patterns

| Wrong | Right |
|-------|-------|
| Trusting scoped state across pages | Always persist before navigating |
| Not checking `IsLoaded` | Call `EnsureSessionLoadedAsync()` first |
| Using `forceLoad: true` | Causes full page reload = new circuit = lost state |
| Storing sensitive data | Never put passwords/tokens in session state |

---

## Checklist

- [ ] `SessionState` with `LoadFrom()`/`ToEntity()`/`IsLoaded`
- [ ] `UserSession` entity + `ISessionRepository`
- [ ] `SessionMiddleware` with HttpOnly + Secure + SameSite cookie
- [ ] Middleware registered BEFORE routing in Program.cs
- [ ] `SessionAwareComponent` base class for DRY
- [ ] Session cleanup scheduled for expired sessions

---

*MORPH-SPEC by Polymorphism Tech*

---

## Code Review Checklist

Use this checklist during code review for Blazor state management:

### Session State

- [ ] **SessionState checked for `IsLoaded` before use?**
- [ ] **SessionState has `LoadFrom()` method implemented?**
- [ ] **Critical state is persisted to database on changes?**
- [ ] **No reliance on scoped state surviving navigation?**

### Navigation

- [ ] **`forceLoad: true` is avoided unless absolutely necessary?**
- [ ] **If `forceLoad` is used, state is persisted BEFORE navigation?**
- [ ] **Deep links work correctly (state loaded from DB)?**

### Security

- [ ] **No sensitive data stored in SessionState (passwords, tokens)?**
- [ ] **Session cookie is HttpOnly and Secure?**

---

*MORPH-SPEC by Polymorphism Tech*
