From ea5b177e551995b101747cfce2d0b8fa8c1f5c8f Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Tue, 24 Mar 2026 11:13:23 -0400 Subject: [PATCH 1/9] feat: implement ViewStateDictionary + IsPostBack core (Phase 1) Implements the core ViewState/PostBack infrastructure per the approved architecture proposal: - ViewStateDictionary: IDictionary implementation with null-safe indexer (Web Forms compat), GetValueOrDefault/Set convenience methods, IsDirty tracking, IDataProtector-based Serialize/Deserialize, JsonElement type coercion for round-trip fidelity - BaseWebFormsComponent: ViewState upgraded from Dictionary to ViewStateDictionary, [Obsolete] removed, IsPostBack with mode-adaptive logic (SSR: HTTP method check, Interactive: _hasInitialized flag), CurrentRenderMode/IsHttpContextAvailable properties, RenderViewStateField for SSR hidden field emission, IDataProtectionProvider injection, ViewState deserialization from form POST in OnInitializedAsync - WebFormsPageBase: ViewState upgraded to ViewStateDictionary, [Obsolete] removed, IsPostBack with same mode-adaptive logic, OnInitialized override to track _hasInitialized - WebFormsRenderMode enum: StaticSSR, InteractiveServer Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BaseWebFormsComponent.cs | 108 ++++++++- .../ViewStateDictionary.cs | 211 ++++++++++++++++++ .../WebFormsPageBase.cs | 47 +++- .../WebFormsRenderMode.cs | 20 ++ 4 files changed, 372 insertions(+), 14 deletions(-) create mode 100644 src/BlazorWebFormsComponents/ViewStateDictionary.cs create mode 100644 src/BlazorWebFormsComponents/WebFormsRenderMode.cs diff --git a/src/BlazorWebFormsComponents/BaseWebFormsComponent.cs b/src/BlazorWebFormsComponents/BaseWebFormsComponent.cs index 9daea05f..bd6ae2a1 100644 --- a/src/BlazorWebFormsComponents/BaseWebFormsComponent.cs +++ b/src/BlazorWebFormsComponents/BaseWebFormsComponent.cs @@ -2,6 +2,7 @@ using BlazorWebFormsComponents.Theming; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.JSInterop; @@ -146,11 +147,53 @@ void ParentWrappingBuildRenderTree(RenderTreeBuilder builder) public string ToolTip { get; set; } /// - /// ViewState is supported for compatibility with those components and pages that add and retrieve items from ViewState.!-- It is not binary compatible, but is syntax compatible + /// Dictionary-based state storage emulating ASP.NET Web Forms ViewState. + /// In ServerInteractive mode, persists for the component's lifetime (in-memory). + /// In SSR mode, round-trips via a protected hidden form field. + /// + /// Migration note: This enables Web Forms ViewState-backed property + /// patterns to work unchanged. For new Blazor code, prefer [Parameter] properties + /// and component fields. /// - /// - [Obsolete("ViewState is supported for compatibility and is discouraged for future use")] - public Dictionary ViewState { get; } = new Dictionary(); + public ViewStateDictionary ViewState { get; } = new(); + + /// + /// Returns true when the current request is a postback (form POST in SSR mode) + /// or after the first initialization (in ServerInteractive mode). + /// Matches the ASP.NET Web Forms Page.IsPostBack semantics. + /// + public bool IsPostBack + { + get + { + // SSR mode: HttpContext is available — check HTTP method + if (HttpContextAccessor?.HttpContext is { } context) + return HttpMethods.IsPost(context.Request.Method); + + // ServerInteractive mode: track initialization state + return _hasInitialized; + } + } + + /// + /// Detects the current rendering mode based on HttpContext availability. + /// + protected WebFormsRenderMode CurrentRenderMode + => IsHttpContextAvailable ? WebFormsRenderMode.StaticSSR : WebFormsRenderMode.InteractiveServer; + + /// + /// Returns true when an is available + /// (SSR or pre-render), false during interactive WebSocket rendering. + /// + protected bool IsHttpContextAvailable + => HttpContextAccessor?.HttpContext is not null; + + /// + /// Optional data protection provider for ViewState encryption in SSR mode. + /// Null when not registered — ViewState serialization is skipped in that case. + /// + [Inject] + private IDataProtectionProvider DataProtectionProvider { get; set; } /// /// Is the content of this component rendered and visible to your users? @@ -211,6 +254,7 @@ public virtual void Focus() [Parameter] public EventCallback OnUnload { get; set; } private bool _UnloadTriggered = false; + private bool _hasInitialized; /// /// Event handler to mimic the Web Forms OnDisposed handler and triggered in the Dispose method of this class @@ -281,6 +325,32 @@ protected virtual void ApplyThemeSkin(ControlSkin skin) { } protected override async Task OnInitializedAsync() { + // SSR mode: deserialize ViewState from form POST data + if (CurrentRenderMode == WebFormsRenderMode.StaticSSR + && DataProtectionProvider is not null) + { + var context = HttpContextAccessor.HttpContext!; + if (HttpMethods.IsPost(context.Request.Method) + && context.Request.HasFormContentType) + { + var fieldName = $"__bwfc_viewstate_{ID}"; + if (context.Request.Form.TryGetValue(fieldName, out var payload) + && !string.IsNullOrEmpty(payload)) + { + var protector = DataProtectionProvider.CreateProtector("BWFC.ViewState"); + try + { + var deserialized = ViewStateDictionary.Deserialize(payload!, protector); + ViewState.LoadFrom(deserialized); + } + catch (System.Security.Cryptography.CryptographicException) + { + // Tampered or expired payload — fail-safe to empty ViewState + } + } + } + } + Parent?.Controls.Add(this); if (OnInit.HasDelegate) @@ -294,6 +364,7 @@ protected override async Task OnInitializedAsync() if (OnPreRender.HasDelegate) await OnPreRender.InvokeAsync(EventArgs.Empty); + _hasInitialized = true; } protected override async Task OnAfterRenderAsync(bool firstRender) @@ -330,6 +401,35 @@ protected override async Task OnAfterRenderAsync(bool firstRender) } + /// + /// Renders a hidden form field containing the protected (encrypted + signed) ViewState + /// when running in SSR mode and the ViewState has been modified. + /// Call this from a component's BuildRenderTree to enable ViewState persistence + /// across form POSTs in static SSR mode. + /// + /// The to emit the hidden field into. + protected void RenderViewStateField(RenderTreeBuilder builder) + { + if (CurrentRenderMode != WebFormsRenderMode.StaticSSR + || !ViewState.IsDirty + || DataProtectionProvider is null) + { + return; + } + + var protector = DataProtectionProvider.CreateProtector("BWFC.ViewState"); + var payload = ViewState.Serialize(protector); + var fieldName = $"__bwfc_viewstate_{ID}"; + + builder.OpenElement(0, "input"); + builder.AddAttribute(1, "type", "hidden"); + builder.AddAttribute(2, "name", fieldName); + builder.AddAttribute(3, "value", payload); + builder.CloseElement(); + + ViewState.MarkClean(); + } + protected virtual void HandleUnknownAttributes() { } diff --git a/src/BlazorWebFormsComponents/ViewStateDictionary.cs b/src/BlazorWebFormsComponents/ViewStateDictionary.cs new file mode 100644 index 00000000..8a63dd74 --- /dev/null +++ b/src/BlazorWebFormsComponents/ViewStateDictionary.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text.Json; +using Microsoft.AspNetCore.DataProtection; + +namespace BlazorWebFormsComponents; + +/// +/// Dictionary-based state storage emulating ASP.NET Web Forms ViewState. +/// In ServerInteractive mode, persists for the component's lifetime (in-memory). +/// In SSR mode, round-trips via a protected hidden form field. +/// +/// Migration note: This enables Web Forms ViewState-backed property +/// patterns to work unchanged. For new Blazor code, prefer [Parameter] properties +/// and component fields. +/// +public class ViewStateDictionary : IDictionary +{ + private readonly Dictionary _store = new(); + + /// + /// Gets or sets the value associated with the specified key. + /// Returns null for missing keys (matches Web Forms ViewState behavior). + /// + public object? this[string key] + { + get => _store.TryGetValue(key, out var value) ? value : null; + set + { + _store[key] = value; + IsDirty = true; + } + } + + /// + /// Type-safe convenience method to retrieve a value from ViewState. + /// Handles JSON deserialization type coercion automatically. + /// + /// The expected value type. + /// The key to look up. + /// Value to return if the key is missing or null. + /// The stored value converted to , or . + public T GetValueOrDefault(string key, T defaultValue = default!) + { + if (!_store.TryGetValue(key, out var value) || value is null) + return defaultValue; + + return CoerceValue(value); + } + + /// + /// Type-safe convenience method to store a value in ViewState. + /// + /// The value type. + /// The key to store under. + /// The value to store. + public void Set(string key, T value) + { + _store[key] = value; + IsDirty = true; + } + + /// + /// Indicates whether the dictionary has been modified since the last + /// call. Used to skip serialization when no changes occurred. + /// + internal bool IsDirty { get; private set; } + + /// + /// Resets the dirty flag after serialization. + /// + internal void MarkClean() => IsDirty = false; + + /// + /// Serializes the dictionary to a protected (encrypted + signed) string + /// suitable for embedding in a hidden form field. + /// + /// The data protector to encrypt and sign the payload. + /// A protected string containing the serialized ViewState. + internal string Serialize(IDataProtector protector) + { + var json = JsonSerializer.Serialize(_store); + return protector.Protect(json); + } + + /// + /// Deserializes a protected ViewState payload back into a . + /// Values are stored as for lazy type coercion. + /// + /// The protected string from the hidden form field. + /// The data protector to decrypt and verify the payload. + /// A new populated with the deserialized state. + internal static ViewStateDictionary Deserialize(string protectedPayload, IDataProtector protector) + { + var json = protector.Unprotect(protectedPayload); + var dict = new ViewStateDictionary(); + var values = JsonSerializer.Deserialize>(json); + if (values is not null) + { + foreach (var kvp in values) + { + dict._store[kvp.Key] = kvp.Value; + } + } + return dict; + } + + /// + /// Merges state from another (typically from deserialization) + /// into this instance. Existing keys are overwritten. + /// + /// The source dictionary to merge from. + internal void LoadFrom(ViewStateDictionary other) + { + foreach (var kvp in other._store) + { + _store[kvp.Key] = kvp.Value; + } + } + + /// + /// Coerces a stored value (which may be a after deserialization) + /// to the requested type . + /// + private static T CoerceValue(object value) + { + if (value is T typed) + return typed; + + if (value is JsonElement element) + return element.Deserialize()!; + + return (T)Convert.ChangeType(value, typeof(T)); + } + + #region IDictionary implementation + + /// + public ICollection Keys => _store.Keys; + + /// + public ICollection Values => _store.Values; + + /// + public int Count => _store.Count; + + /// + public bool IsReadOnly => false; + + /// + public void Add(string key, object? value) + { + _store.Add(key, value); + IsDirty = true; + } + + /// + public bool ContainsKey(string key) => _store.ContainsKey(key); + + /// + public bool Remove(string key) + { + var removed = _store.Remove(key); + if (removed) IsDirty = true; + return removed; + } + + /// + public bool TryGetValue(string key, out object? value) => _store.TryGetValue(key, out value); + + /// + public void Add(KeyValuePair item) + { + ((ICollection>)_store).Add(item); + IsDirty = true; + } + + /// + public void Clear() + { + _store.Clear(); + IsDirty = true; + } + + /// + public bool Contains(KeyValuePair item) + => ((ICollection>)_store).Contains(item); + + /// + public void CopyTo(KeyValuePair[] array, int arrayIndex) + => ((ICollection>)_store).CopyTo(array, arrayIndex); + + /// + public bool Remove(KeyValuePair item) + { + var removed = ((ICollection>)_store).Remove(item); + if (removed) IsDirty = true; + return removed; + } + + /// + public IEnumerator> GetEnumerator() => _store.GetEnumerator(); + + /// + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + #endregion +} diff --git a/src/BlazorWebFormsComponents/WebFormsPageBase.cs b/src/BlazorWebFormsComponents/WebFormsPageBase.cs index 81a5f830..c9e93e58 100644 --- a/src/BlazorWebFormsComponents/WebFormsPageBase.cs +++ b/src/BlazorWebFormsComponents/WebFormsPageBase.cs @@ -62,12 +62,29 @@ public string MetaKeywords } /// -/// Always returns false. Blazor has no postback model. -/// Exists so that if (!IsPostBack) { ... } compiles and executes correctly — -/// the guarded block always runs, which is the correct behavior for -/// OnInitialized (first-render) context. +/// Returns true when the current request is a postback (form POST in SSR mode) +/// or after the first initialization (in ServerInteractive mode). +/// Matches the ASP.NET Web Forms Page.IsPostBack semantics: +/// +/// SSR GET request → false +/// SSR POST request → true +/// ServerInteractive first render → false +/// ServerInteractive subsequent renders → true +/// /// -public bool IsPostBack => false; +public bool IsPostBack +{ + get + { + // SSR mode: HttpContext is available — check HTTP method + if (_httpContextAccessor?.HttpContext is { } context) + return HttpMethods.IsPost(context.Request.Method); + + // ServerInteractive mode: track initialization state + return _hasInitialized; + } +} +private bool _hasInitialized; /// /// Returns this instance, enabling Page.Title, Page.MetaDescription, @@ -95,12 +112,22 @@ protected RequestShim Request => new(_httpContextAccessor.HttpContext, _navigationManager, _logger); /// -/// In-memory dictionary emulating Web Forms ViewState. -/// Values do NOT survive navigation — they live only for the lifetime of -/// the component instance (equivalent to private fields). +/// Dictionary-based state storage emulating ASP.NET Web Forms ViewState. +/// In ServerInteractive mode, persists for the component's lifetime (in-memory). +/// In SSR mode, round-trips via a protected hidden form field. +/// +/// Migration note: This enables Web Forms ViewState-backed property +/// patterns to work unchanged. For new Blazor code, prefer [Parameter] properties +/// and component fields. /// -[Obsolete("ViewState is in-memory only in Blazor. Values do not survive navigation.")] -public Dictionary ViewState { get; } = new(); +public ViewStateDictionary ViewState { get; } = new(); + +/// +protected override void OnInitialized() +{ + base.OnInitialized(); + _hasInitialized = true; +} /// /// Generates a URL for the named route with the specified parameters. diff --git a/src/BlazorWebFormsComponents/WebFormsRenderMode.cs b/src/BlazorWebFormsComponents/WebFormsRenderMode.cs new file mode 100644 index 00000000..28281f53 --- /dev/null +++ b/src/BlazorWebFormsComponents/WebFormsRenderMode.cs @@ -0,0 +1,20 @@ +namespace BlazorWebFormsComponents; + +/// +/// Identifies the current rendering mode of a Blazor Web Forms component. +/// Used internally by ViewState and IsPostBack to adapt behavior automatically. +/// +public enum WebFormsRenderMode +{ + /// + /// Static Server-Side Rendering or pre-render — HttpContext is available, no SignalR circuit. + /// ViewState persists via a protected hidden form field. IsPostBack checks the HTTP method. + /// + StaticSSR, + + /// + /// Interactive Server rendering — SignalR circuit is active, no HttpContext. + /// ViewState persists in component instance memory. IsPostBack tracks initialization state. + /// + InteractiveServer +} From 0e9f2f59199ed1a4a70ea77f696bccf234900e31 Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Tue, 24 Mar 2026 11:15:34 -0400 Subject: [PATCH 2/9] fix: correct formatting and update footer year in Site.Master --- samples/WingtipToys/WingtipToys/Site.Master | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/samples/WingtipToys/WingtipToys/Site.Master b/samples/WingtipToys/WingtipToys/Site.Master index f9eaa39b..f9373748 100644 --- a/samples/WingtipToys/WingtipToys/Site.Master +++ b/samples/WingtipToys/WingtipToys/Site.Master @@ -50,7 +50,7 @@ + +@code { + [Parameter] public string Placeholder { get; set; } = "Search..."; + [Parameter] public EventCallback OnSearch { get; set; } +} +``` + +**Migration Changes:** + +| Aspect | ASCX | Blazor | Diff | +|--------|------|--------|------| +| **Container** | `