diff --git a/.squad/agents/jubilee/history.md b/.squad/agents/jubilee/history.md index e3273590..c629f92b 100644 --- a/.squad/agents/jubilee/history.md +++ b/.squad/agents/jubilee/history.md @@ -473,3 +473,16 @@ This wave establishes **documentation patterns** that will guide future control - **Page uses `@inject ClientScriptShim ClientScript`** — injected as scoped service. Calls `FlushAsync(JS)` in `OnAfterRenderAsync` since the page doesn't inherit BaseWebFormsComponent. - **Pattern:** Source code section at bottom showing complete `@code` block with escaped `@@` directives for display. Follows established migration demo conventions. - **Build verified:** 0 errors, pre-existing BL0005 warnings only. + +### PostBack & ScriptManager Demo Page (ClientScript Phase 2) + +- **Created** `samples/AfterBlazorServerSide/Components/Pages/ControlSamples/PostBackDemo/Index.razor` — three-section demo page for Phase 2 ClientScript postback shims. +- **Section 1: GetPostBackEventReference** — button with `onclick="@_postBackScript"` fires `__doPostBack` from JS. PostBack event on WebFormsPageBase receives it server-side. Stable IDs: `postback-button`, `postback-script`, `postback-result`. +- **Section 2: GetPostBackClientHyperlink** — anchor `href="@_postBackHyperlink"` triggers postback via `javascript:__doPostBack(...)` URL. Stable IDs: `postback-link`, `hyperlink-script`, `hyperlink-result`. +- **Section 3: ScriptManager.GetCurrent** — uses `ScriptManagerShim.GetCurrent(this)` to register startup script that writes into `#scriptmanager-target`. Same Web Forms code pattern. +- **Source Code section** at bottom with escaped `@@` directives. +- **Inherits WebFormsPageBase** (not `@inject`) — gives access to `ClientScript`, `PostBack` event, and `ScriptManagerShim.GetCurrent(this)`. This differs from the ClientScriptShim demo which uses `@inject`. +- **Updated** `ComponentCatalog.cs` — added "PostBack Demo" entry in "Migration Helpers" category after "IsPostBack". +- **Updated** `ComponentList.razor` — added PostBack & ScriptManager link in Migration Helpers section. +- **Build verified:** 0 errors, pre-existing BL0005 warnings only. +- **Lesson:** Pages that need the PostBack event must `@inherits WebFormsPageBase`. The ClientScript property is available via inheritance (no separate `@inject` needed). `OnAfterRenderAsync` in WebFormsPageBase handles `FlushAsync` automatically. diff --git a/docs/Analyzers/BWFC022.md b/docs/Analyzers/BWFC022.md index 17118ca7..3704ca9a 100644 --- a/docs/Analyzers/BWFC022.md +++ b/docs/Analyzers/BWFC022.md @@ -78,7 +78,7 @@ If you prefer to modernize your code now, rewrite to `IJSRuntime` directly. The | `RegisterStartupScript()` | `ClientScriptShim` (⭐ Easy) or `OnAfterRenderAsync()` + `IJSRuntime` (⭐⭐ Moderate) | ⭐ Easy | | `RegisterClientScriptInclude()` | `ClientScriptShim` (⭐ Easy) or `", true); + + clientScript.IsStartupScriptRegistered(typeof(ScriptManagerShimTests), "tagged").ShouldBeTrue(); + } + + #endregion + + #region Delegation — RegisterClientScriptBlock + + [Fact] + public void RegisterClientScriptBlock_DelegatesToClientScript() + { + var clientScript = CreateClientScriptShim(); + var sm = new ScriptManagerShim(clientScript); + + sm.RegisterClientScriptBlock(typeof(ScriptManagerShimTests), "block1", "var x = 1;", false); + + clientScript.IsClientScriptBlockRegistered(typeof(ScriptManagerShimTests), "block1").ShouldBeTrue(); + } + + #endregion + + #region Delegation — RegisterClientScriptInclude + + [Fact] + public void RegisterClientScriptInclude_DelegatesToClientScript() + { + var clientScript = CreateClientScriptShim(); + var sm = new ScriptManagerShim(clientScript); + + sm.RegisterClientScriptInclude(typeof(ScriptManagerShimTests), "inc1", "https://cdn.example.com/lib.js"); + + clientScript.IsClientScriptIncludeRegistered("inc1").ShouldBeTrue(); + } + + #endregion +} diff --git a/src/BlazorWebFormsComponents/ClientScriptShim.cs b/src/BlazorWebFormsComponents/ClientScriptShim.cs index df05bead..da4530b6 100644 --- a/src/BlazorWebFormsComponents/ClientScriptShim.cs +++ b/src/BlazorWebFormsComponents/ClientScriptShim.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Text.RegularExpressions; using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Logging; using Microsoft.JSInterop; @@ -192,45 +193,46 @@ public async Task FlushAsync(IJSRuntime jsRuntime) _scriptIncludes.Clear(); } - // ─── Unsupported Methods ─────────────────────────────────────────── + // ─── PostBack / Callback Methods ────────────────────────────────── /// - /// Not supported in Blazor. Throws - /// with migration guidance. + /// Returns a JavaScript string that triggers a postback, mirroring the + /// Web Forms ClientScriptManager.GetPostBackEventReference behavior. + /// The returned string calls __doPostBack(eventTarget, eventArgument), + /// which is defined in bwfc-postback.js. /// - /// Always thrown. + /// The control initiating the postback (used to resolve an ID). + /// The event argument string. + /// A JavaScript expression string, e.g. __doPostBack('Button1', ''). public string GetPostBackEventReference(object control, string argument) { - throw new NotSupportedException( - "GetPostBackEventReference is not supported in Blazor. " + - "Use @onclick / EventCallback instead. " + - "See: docs/Migration/ClientScriptMigrationGuide.md"); + var id = ResolveControlId(control); + return $"__doPostBack('{EscapeJsString(id)}', '{EscapeJsString(argument ?? string.Empty)}')"; } /// - /// Not supported in Blazor. Throws - /// with migration guidance. + /// Returns a javascript: URL that triggers a postback, mirroring + /// ClientScriptManager.GetPostBackClientHyperlink. /// - /// Always thrown. + /// The control initiating the postback. + /// The event argument string. + /// A javascript:__doPostBack(...) URL string. public string GetPostBackClientHyperlink(object control, string argument) { - throw new NotSupportedException( - "GetPostBackClientHyperlink is not supported in Blazor. " + - "Use NavigationManager or instead. " + - "See: docs/Migration/ClientScriptMigrationGuide.md"); + return $"javascript:{GetPostBackEventReference(control, argument)}"; } /// - /// Not supported in Blazor. Throws - /// with migration guidance. + /// Returns a JavaScript expression that invokes the BWFC callback bridge, + /// mirroring ClientScriptManager.GetCallbackEventReference. + /// The returned expression calls __bwfc_callback defined in + /// bwfc-postback.js. /// - /// Always thrown. - public string GetCallbackEventReference(object control, string argument, string clientCallback, string context, string clientErrorCallback, bool useAsync) + public string GetCallbackEventReference(object control, string argument, + string clientCallback, string context, string clientErrorCallback, bool useAsync) { - throw new NotSupportedException( - "GetCallbackEventReference is not supported in Blazor. " + - "Use IJSRuntime / EventCallback for JS-to-.NET interop. " + - "See: docs/Migration/ClientScriptMigrationGuide.md"); + var id = ResolveControlId(control); + return $"__bwfc_callback('{EscapeJsString(id)}', '{EscapeJsString(argument ?? string.Empty)}', {clientCallback ?? "null"}, '{EscapeJsString(context ?? string.Empty)}', {clientErrorCallback ?? "null"})"; } // ─── Helpers ─────────────────────────────────────────────────────── @@ -250,4 +252,18 @@ private static string EscapeJsString(string value) { return value.Replace("\\", "\\\\").Replace("'", "\\'"); } + + /// + /// Resolves a control reference to an ID string suitable for use in + /// JavaScript postback/callback expressions. + /// Prefers when available. + /// + private static string ResolveControlId(object control) + { + if (control is BaseWebFormsComponent bwfc && !string.IsNullOrEmpty(bwfc.ID)) + return bwfc.ID; + if (control is ComponentBase) + return control.GetType().Name; + return control?.GetType().Name ?? "unknown"; + } } diff --git a/src/BlazorWebFormsComponents/PostBackEventArgs.cs b/src/BlazorWebFormsComponents/PostBackEventArgs.cs new file mode 100644 index 00000000..44e06056 --- /dev/null +++ b/src/BlazorWebFormsComponents/PostBackEventArgs.cs @@ -0,0 +1,23 @@ +using System; + +namespace BlazorWebFormsComponents; + +/// +/// Event arguments for postback events, mirroring the Web Forms postback model. +/// Contains the (control ID) and +/// that were passed to __doPostBack(eventTarget, eventArgument). +/// +public class PostBackEventArgs : EventArgs +{ + /// The ID of the control that initiated the postback. + public string EventTarget { get; } + + /// The argument string associated with the postback. + public string EventArgument { get; } + + public PostBackEventArgs(string eventTarget, string eventArgument) + { + EventTarget = eventTarget; + EventArgument = eventArgument; + } +} diff --git a/src/BlazorWebFormsComponents/ScriptManagerShim.cs b/src/BlazorWebFormsComponents/ScriptManagerShim.cs new file mode 100644 index 00000000..2b1bea41 --- /dev/null +++ b/src/BlazorWebFormsComponents/ScriptManagerShim.cs @@ -0,0 +1,66 @@ +using System; + +namespace BlazorWebFormsComponents; + +/// +/// Compatibility shim for System.Web.UI.ScriptManager. +/// Provides the static factory method that Web Forms +/// code uses to obtain a ScriptManager instance from the page, and +/// delegates all RegisterXxx calls to . +/// +public class ScriptManagerShim +{ + private readonly ClientScriptShim _clientScript; + + /// + /// Initializes a new instance backed by the specified . + /// + public ScriptManagerShim(ClientScriptShim clientScript) + { + _clientScript = clientScript ?? throw new ArgumentNullException(nameof(clientScript)); + } + + /// + /// Static factory matching the Web Forms ScriptManager.GetCurrent(Page) pattern. + /// Resolves the from the page/component instance. + /// + /// + /// A or instance. + /// + /// A wrapping the component's ClientScript. + /// + /// Thrown when is not a recognized BWFC type or has no ClientScript. + /// + public static ScriptManagerShim GetCurrent(object page) + { + if (page is BaseWebFormsComponent component && component.ClientScript != null) + return new ScriptManagerShim(component.ClientScript); + if (page is WebFormsPageBase pageBase && pageBase.ClientScript != null) + return new ScriptManagerShim(pageBase.ClientScript); + + throw new InvalidOperationException( + "ScriptManager.GetCurrent() requires a component derived from " + + "BaseWebFormsComponent or WebFormsPageBase with a registered ClientScriptShim."); + } + + // ─── Delegated Registration Methods ─────────────────────────────── + + /// + public void RegisterStartupScript(Type type, string key, string script, bool addScriptTags) + => _clientScript.RegisterStartupScript(type, key, script, addScriptTags); + + /// + /// Overload accepting a control reference (ignored) to match the Web Forms + /// ScriptManager.RegisterStartupScript(Control, Type, String, String, Boolean) signature. + /// + public void RegisterStartupScript(object control, Type type, string key, string script, bool addScriptTags) + => _clientScript.RegisterStartupScript(type, key, script, addScriptTags); + + /// + public void RegisterClientScriptBlock(Type type, string key, string script, bool addScriptTags) + => _clientScript.RegisterClientScriptBlock(type, key, script, addScriptTags); + + /// + public void RegisterClientScriptInclude(Type type, string key, string url) + => _clientScript.RegisterClientScriptInclude(type, key, url); +} diff --git a/src/BlazorWebFormsComponents/ServiceCollectionExtensions.cs b/src/BlazorWebFormsComponents/ServiceCollectionExtensions.cs index 32c760f3..fe57087f 100644 --- a/src/BlazorWebFormsComponents/ServiceCollectionExtensions.cs +++ b/src/BlazorWebFormsComponents/ServiceCollectionExtensions.cs @@ -43,6 +43,7 @@ public static IServiceCollection AddBlazorWebFormsComponents(this IServiceCollec services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(sp => new ScriptManagerShim(sp.GetRequiredService())); var options = new BlazorWebFormsComponentsOptions(); configure?.Invoke(options); diff --git a/src/BlazorWebFormsComponents/WebFormsPageBase.cs b/src/BlazorWebFormsComponents/WebFormsPageBase.cs index 1cbd00e7..894555e9 100644 --- a/src/BlazorWebFormsComponents/WebFormsPageBase.cs +++ b/src/BlazorWebFormsComponents/WebFormsPageBase.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Logging; +using Microsoft.JSInterop; namespace BlazorWebFormsComponents; @@ -12,10 +14,10 @@ namespace BlazorWebFormsComponents; /// Base class for converted ASP.NET Web Forms pages. Provides Page.Title, /// Page.MetaDescription, Page.MetaKeywords, IsPostBack, Response.Redirect, /// Response.Cookies, Request.Cookies, Request.QueryString, Request.Url, -/// ViewState, and GetRouteUrl compatibility so that Web Forms code-behind -/// patterns survive migration with minimal changes. +/// ViewState, GetRouteUrl, ClientScript, and PostBack compatibility so that +/// Web Forms code-behind patterns survive migration with minimal changes. /// -public abstract class WebFormsPageBase : ComponentBase +public abstract class WebFormsPageBase : ComponentBase, IAsyncDisposable { [Inject] private IPageService _pageService { get; set; } = null!; [Inject] private NavigationManager _navigationManager { get; set; } = null!; @@ -25,6 +27,25 @@ public abstract class WebFormsPageBase : ComponentBase [Inject] private SessionShim _sessionShim { get; set; } = null!; [Inject] private IWebHostEnvironment _webHostEnvironment { get; set; } = null!; [Inject] private CacheShim _cacheShim { get; set; } = null!; +[Inject] private IJSRuntime _jsRuntime { get; set; } = null!; +[Inject] private ClientScriptShim _clientScriptShim { get; set; } = null!; + +/// +/// Provides access to client script registration methods, emulating +/// Page.ClientScript from ASP.NET Web Forms. +/// +public ClientScriptShim ClientScript => _clientScriptShim; + +// ─── PostBack Support ───────────────────────────────────────────── + +private DotNetObjectReference? _postBackRef; +private string? _postBackTargetId; + +/// +/// Raised when a postback is triggered from JavaScript via __doPostBack(). +/// Subscribe to this event in derived pages to handle postback actions. +/// +public event EventHandler? PostBack; /// /// Provides dictionary-style Session["key"] access, emulating @@ -237,4 +258,105 @@ private void RequireHttpContext(string memberName) $"rendering (WebSocket mode). Use {nameof(IsHttpContextAvailable)} to guard " + $"calls to this member, or ensure the page runs in SSR mode."); } + +// ─── PostBack JS Interop ────────────────────────────────────────── + +// Inline bootstrap ensures __doPostBack and registration functions exist +// before any postback target is registered. The standalone bwfc-postback.js +// file is also available for manual "); @@ -50,7 +49,7 @@ protected override async Task OnInitializedAsync() ClientScript.RegisterStartupScript(this.GetType(), "smScript", "alert('hello');", true); // Pattern 6: ScriptManager.GetCurrent - // TODO(bwfc-general): ScriptManager.GetCurrent() has no Blazor equivalent. Use IJSRuntime directly. + var sm = ScriptManager.GetCurrent(this); } } } diff --git a/tests/BlazorWebFormsComponents.Cli.Tests/TransformUnit/ClientScriptTransformTests.cs b/tests/BlazorWebFormsComponents.Cli.Tests/TransformUnit/ClientScriptTransformTests.cs index 2809dd47..cb3ac137 100644 --- a/tests/BlazorWebFormsComponents.Cli.Tests/TransformUnit/ClientScriptTransformTests.cs +++ b/tests/BlazorWebFormsComponents.Cli.Tests/TransformUnit/ClientScriptTransformTests.cs @@ -229,10 +229,10 @@ void Page_Load() #endregion - #region TC38 — GetPostBackEventReference and ScriptManager.GetCurrent + #region TC38 — GetPostBackEventReference and ScriptManager.GetCurrent (Phase 2: shim-preserving) [Fact] - public void TC38_GetPostBackEventReference_EmitsTodoWithEventCallbackGuidance() + public void TC38_GetPostBackEventReference_StripsPagePrefix() { var input = @"namespace MyApp { @@ -246,13 +246,14 @@ void DoWork() }"; var result = _transform.Apply(input, TestMetadata(input)); - Assert.Contains("TODO(bwfc-general)", result); - Assert.Contains("@onclick", result); - Assert.Contains("EventCallback", result); + Assert.Contains("ClientScript.GetPostBackEventReference(btnSubmit", result); + Assert.DoesNotContain("Page.ClientScript", result); + Assert.DoesNotContain("// TODO(bwfc-general): Replace __doPostBack", result); + Assert.DoesNotContain("// Original:", result); } [Fact] - public void TC38_GetPostBackEventReference_PreservesOriginalAsComment() + public void TC38_GetPostBackEventReference_StripsThisPrefix() { var input = @"namespace MyApp { @@ -260,18 +261,38 @@ public partial class MyPage { void DoWork() { - var postbackRef = Page.ClientScript.GetPostBackEventReference(btnSubmit, ""validate""); + var postbackRef = this.ClientScript.GetPostBackEventReference(btnSubmit, ""validate""); } } }"; var result = _transform.Apply(input, TestMetadata(input)); - Assert.Contains("// Original:", result); - Assert.Contains("GetPostBackEventReference", result); + Assert.Contains("ClientScript.GetPostBackEventReference(btnSubmit", result); + Assert.DoesNotContain("this.ClientScript", result); + Assert.DoesNotContain("// Original:", result); } [Fact] - public void TC38_ScriptManagerGetCurrent_EmitsTodo() + public void TC38_GetPostBackEventReference_BareCall_SetsShimFlag() + { + var input = @"namespace MyApp +{ + public partial class MyPage + { + void DoWork() + { + var postbackRef = ClientScript.GetPostBackEventReference(btnSubmit, ""validate""); + } + } +}"; + var result = _transform.Apply(input, TestMetadata(input)); + + Assert.Contains("ClientScript.GetPostBackEventReference(btnSubmit", result); + Assert.Contains("ClientScriptShim", result); + } + + [Fact] + public void TC38_ScriptManagerGetCurrent_ConvertsPageToThis() { var input = @"namespace MyApp { @@ -285,13 +306,32 @@ void Page_Load() }"; var result = _transform.Apply(input, TestMetadata(input)); - Assert.Contains("TODO(bwfc-general)", result); - Assert.Contains("ScriptManager.GetCurrent()", result); - Assert.Contains("IJSRuntime", result); + Assert.Contains("ScriptManager.GetCurrent(this)", result); + Assert.DoesNotContain("ScriptManager.GetCurrent(Page)", result); + Assert.DoesNotContain("ScriptManager.GetCurrent() has no Blazor equivalent", result); + } + + [Fact] + public void TC38_ScriptManagerGetCurrent_WithThisDotPage_ConvertsToThis() + { + var input = @"namespace MyApp +{ + public partial class MyPage + { + void Page_Load() + { + var sm = ScriptManager.GetCurrent(this.Page); + } + } +}"; + var result = _transform.Apply(input, TestMetadata(input)); + + Assert.Contains("ScriptManager.GetCurrent(this)", result); + Assert.DoesNotContain("this.Page", result); } [Fact] - public void TC38_ScriptManagerGetCurrent_WithThis_EmitsTodo() + public void TC38_ScriptManagerGetCurrent_PreservesThis() { var input = @"namespace MyApp { @@ -305,8 +345,49 @@ void Page_Load() }"; var result = _transform.Apply(input, TestMetadata(input)); - Assert.Contains("TODO(bwfc-general)", result); - Assert.Contains("ScriptManager.GetCurrent()", result); + Assert.Contains("ScriptManager.GetCurrent(this)", result); + Assert.DoesNotContain("ScriptManager.GetCurrent() has no Blazor equivalent", result); + Assert.Contains("ScriptManagerShim", result); + } + + [Fact] + public void TC38_ScriptManagerGetCurrent_AddsScriptManagerShimComment() + { + var input = @"namespace MyApp +{ + public partial class MyPage + { + void Page_Load() + { + var sm = ScriptManager.GetCurrent(Page); + } + } +}"; + var result = _transform.Apply(input, TestMetadata(input)); + + Assert.Contains("ScriptManagerShim", result); + Assert.Contains("ClientScriptShim", result); + } + + [Fact] + public void TC38_ScriptManagerGetCurrent_FullPattern_Preserved() + { + var input = @"namespace MyApp +{ + public partial class MyPage + { + void Page_Load() + { + ScriptManager sm = ScriptManager.GetCurrent(Page); + sm.RegisterStartupScript(this.GetType(), ""key"", ""script"", true); + } + } +}"; + var result = _transform.Apply(input, TestMetadata(input)); + + Assert.Contains("ScriptManager sm = ScriptManager.GetCurrent(this)", result); + Assert.Contains("sm.RegisterStartupScript(this.GetType()", result); + Assert.Contains("ScriptManagerShim", result); } #endregion @@ -342,10 +423,12 @@ void Page_Load() Assert.Contains("ClientScript.RegisterClientScriptBlock", result); // ScriptManager.RegisterStartupScript → ClientScript.RegisterStartupScript Assert.DoesNotContain("ScriptManager.RegisterStartupScript", result); - // Postback → TODO with EventCallback - Assert.Contains("@onclick", result); - // ScriptManager.GetCurrent → TODO - Assert.Contains("ScriptManager.GetCurrent() has no Blazor equivalent", result); + // Postback → preserved with prefix stripped + Assert.Contains("ClientScript.GetPostBackEventReference", result); + Assert.DoesNotContain("Page.ClientScript.GetPostBackEventReference", result); + // ScriptManager.GetCurrent → Page replaced with this + Assert.Contains("ScriptManager.GetCurrent(this)", result); + Assert.DoesNotContain("ScriptManager.GetCurrent(Page)", result); // ClientScriptShim comment injected (not IJSRuntime) Assert.Contains("ClientScriptShim", result); Assert.DoesNotContain("[Inject] private IJSRuntime JS", result); @@ -367,7 +450,7 @@ public partial class MyPage } [Fact] - public void DoesNotAddShimComment_WhenOnlyPostbackRef() + public void AddsShimComment_WhenOnlyPostbackRef() { var input = @"namespace MyApp { @@ -381,7 +464,7 @@ void DoWork() }"; var result = _transform.Apply(input, TestMetadata(input)); - Assert.DoesNotContain("ClientScriptShim", result); + Assert.Contains("ClientScriptShim", result); } [Fact]