Enforce canonical migration standards for ASP.NET Web Forms to Blazor using BWFC. Covers target architecture (.NET 10, Server Interactive), database provider detection, event handler preservation, SelectMethod patterns, and page lifecycle mapping. WHEN: "migration standards", "target architecture", "render mode placement", "page base class", "Layer 1 vs Layer 2".
92
88%
Does it follow best practices?
Impact
100%
1.81xAverage score across 3 eval scenarios
Passed
No known issues
When migrating an ASP.NET Web Forms application to Blazor using BlazorWebFormsComponents, these standards define the canonical target architecture, tooling choices, and migration patterns. Established through five WingtipToys migration benchmark runs and codified as a directive by Jeffrey T. Fritz.
Apply these standards to:
bwfc-migrate.ps1) enhancements| Setting | Standard |
|---|---|
| Framework | .NET 10 (or latest LTS/.NET preview) |
| Project template | dotnet new blazor --interactivity Server |
| Render mode | Global Server Interactive (see Render Mode Placement below) |
| Base class | WebFormsPageBase for pages (@inherits in _Imports.razor); ComponentBase for non-page components |
| Layout | MainLayout.razor with @inherits LayoutComponentBase and @Body |
@rendermodeis a directive attribute, not a standalone directive. It goes on component instances in markup, not in_Imports.razor.
_Imports.razor — add the static using so you can write InteractiveServer instead of RenderMode.InteractiveServer:
@using static Microsoft.AspNetCore.Components.Web.RenderModeApp.razor — apply render mode to the top-level routable components:
<HeadOutlet @rendermode="InteractiveServer" />
...
<Routes @rendermode="InteractiveServer" />This gives every page global server interactivity. Do not place @rendermode InteractiveServer as a line in _Imports.razor — it is not a valid Razor directive and will cause build errors (RZ10003, CS0103, RZ10024).
Reference: ASP.NET Core Blazor render modes
WebFormsPageBase eliminates per-page boilerplate when migrating Web Forms code-behind. Instead of injecting IPageService into every page, a single @inherits directive in _Imports.razor gives all pages access to familiar Web Forms properties.
One-time setup:
_Imports.razor — add the base class directive:@inherits BlazorWebFormsComponents.WebFormsPageBaseMainLayout.razor) — add the Page render component (renders <PageTitle> and <meta> tags):<BlazorWebFormsComponents.Page />Properties available on every page:
| Property | Behavior |
|---|---|
Title | Delegates to IPageService.Title — Page.Title = "X" works unchanged |
MetaDescription | Delegates to IPageService.MetaDescription |
MetaKeywords | Delegates to IPageService.MetaKeywords |
IsPostBack | Always returns false — if (!IsPostBack) always enters block |
Page | Returns this — enables Page.Title = "X" dot syntax |
What is NOT provided (forces proper Blazor migration):
Page.Request — use IHttpContextAccessor or NavigationManagerPage.Response — use NavigationManager for redirectsPage.Session — use scoped DI servicesWhen to still use @inject IPageService: Non-page components (e.g., a shared header or sidebar) that need access to page metadata should inject IPageService directly. WebFormsPageBase only applies to routable pages.
Microsoft.EntityFrameworkCore (10.0.3), .SqlServer (or provider matching original app), .Tools, .DesignWeb.config <connectionStrings> to identify the database provider (System.Data.SqlClient → SqlServer, System.Data.SQLite → Sqlite, Npgsql → PostgreSQL). Install the matching EF Core provider package (Microsoft.EntityFrameworkCore.SqlServer, .Sqlite, Npgsql.EntityFrameworkCore.PostgreSQL, etc.). The L1 script auto-detects this — verify its detection in the [DatabaseProvider] review item. NEVER substitute a different provider than what the original application used.DropCreateDatabaseIfModelChanges with EnsureCreated + idempotent seedIDbContextFactory<T> or scoped DbContext injectiondotnet aspnet-codegenerator identity for scaffoldingSignInManager / UserManager APIs change — full subsystem replacementBWFC components already expose EventCallback parameters with matching Web Forms names:
| Web Forms | BWFC | Action |
|---|---|---|
OnClick="Handler" | OnClick (EventCallback<MouseEventArgs>) | Preserve attribute verbatim — only update handler signature |
OnCommand="Handler" | OnCommand (EventCallback<CommandEventArgs>) | Preserve, update signature |
OnSelectedIndexChanged="Handler" | OnSelectedIndexChanged (EventCallback<ChangeEventArgs>) | Preserve, update signature |
OnTextChanged="Handler" | OnTextChanged (EventCallback<ChangeEventArgs>) | Preserve, update signature |
OnCheckedChanged="Handler" | OnCheckedChanged (EventCallback<ChangeEventArgs>) | Preserve, update signature |
Signature change pattern:
// Web Forms
protected void Button1_Click(object sender, EventArgs e) { ... }
// Blazor (BWFC)
private void Button1_Click(MouseEventArgs e) { ... }
// or
private async Task Button1_Click(MouseEventArgs e) { ... }The script should preserve the attribute and annotate the signature change needed.
| Web Forms Control | BWFC Component | Use Instead Of |
|---|---|---|
<asp:ListView> | <ListView Items="@data"> with ItemTemplate | @foreach + HTML table |
<asp:GridView> | <GridView Items="@data"> with columns | @foreach + <table> |
<asp:FormView> | <FormView Items="@data"> with ItemTemplate | Direct HTML rendering |
<asp:Repeater> | <Repeater Items="@data"> with ItemTemplate | @foreach loops |
<asp:DetailsView> | <DetailsView Items="@data"> with fields | Manual field rendering |
<asp:DataList> | <DataList Items="@data"> with ItemTemplate | @foreach + grid HTML |
SelectMethod PRESERVED: BWFC's DataBoundComponent<ItemType> has a native SelectMethod parameter of type SelectHandler<ItemType> (delegate signature: (int maxRows, int startRowIndex, string sortByExpression, out int totalRowCount) → IQueryable<ItemType>). Convert the Web Forms string method name to a delegate reference: SelectMethod="@productService.GetProducts" (if the service method signature matches) or use explicit lambda wiring: SelectMethod="@((maxRows, startRow, sort, out total) => service.GetProducts(maxRows, startRow, sort, out total))". When SelectMethod is set, DataBoundComponent.OnAfterRenderAsync automatically calls it to populate Items.
⚠️ DO NOT convert SelectMethod to Items= binding. When the original Web Forms markup uses
SelectMethod, the migrated Blazor markup MUST preserveSelectMethodas a delegate reference. Converting toItems=loses the native BWFC data-binding pattern and defeats the purpose of drop-in replacement. The ONLY acceptable alternative is when the original Web Forms markup usedDataSource(notSelectMethod), in which caseItems=is correct.
Session["key"] with a scoped DI serviceIHttpContextAccessor for cookie-based persistence when neededProgram.cs with builder.Services.AddScoped<TService>()Session["CartId"] → CartStateService with cookie-based cart IDWhen linking to minimal API endpoints from Blazor pages, use <form method="post"> or add data-enhance-nav="false" to prevent Blazor's enhanced navigation from intercepting the request. Enhanced navigation handles <a href> clicks as client-side SPA navigation, which breaks links to server endpoints (the request never reaches the server). This applies to all auth endpoints, cart operations, file downloads, and any other minimal API routes.
BWFC TextBox uses @onchange (fires on blur), not @oninput (fires on keystroke). This affects Playwright test interactions:
FillAsync() triggers input events, but the Blazor binding value is NOT committed until the element loses focusRecommended Playwright pattern for form submissions:
// Fill the last field
await lastField.FillAsync("value");
// Trigger blur to commit the binding
await lastField.BlurAsync();
// Wait for binding propagation
await Task.Delay(200);
// Now click submit — the value is committed
await submitButton.ClickAsync();Alternative using keyboard navigation:
// Fill fields
await field1.FillAsync("value1");
await field2.FillAsync("value2");
// Press Tab after the last field to trigger blur
await lastField.PressAsync("Tab");
// Small delay for binding update
await Task.Delay(200);
// Submit
await submitButton.ClickAsync();This is a BWFC-specific behavior that mirrors Web Forms' TextBox TextChanged event semantics — both fire on blur, not on keystroke.
wwwroot/BundleConfig.cs) → explicit <link> tags in App.razor<script> tags in App.razor~/Images/ → /Images/CRITICAL: All local variable declarations in generated Blazor code MUST use
var(implicit typing), not explicit types. This is enforced by.editorconfigas IDE0007 error.
// CORRECT — var for all local declarations
var students = db.Students.ToList();
var product = await productService.GetProductAsync(id);
var count = items.Count();
// WRONG — explicit type declarations cause build failures
List<Student> students = db.Students.ToList();
Product product = await productService.GetProductAsync(id);
int count = items.Count();This applies to both L1-generated scaffolding and L2 Copilot-generated code. IDE0007 is enabled as a build error in /.editorconfig — explicit types will fail the build immediately.
| Web Forms | Blazor | Notes |
|---|---|---|
Page_Load | OnInitializedAsync | One-time init |
Page_PreInit | OnInitializedAsync (early) | Theme setup |
Page_PreRender | OnAfterRenderAsync | Post-render logic |
IsPostBack check | if (!IsPostBack) works AS-IS via WebFormsPageBase | Always enters block; if (IsPostBack) without ! is dead code — flag for review |
Page.Title | Page.Title = "X" works AS-IS via WebFormsPageBase | WebFormsPageBase delegates to IPageService. <BlazorWebFormsComponents.Page /> in layout renders <PageTitle> and <meta> tags. |
Response.Redirect | NavigationManager.NavigateTo() | Inject NavigationManager |
⚠️ CRITICAL: Layer 1 and Layer 2 MUST both run in sequence. Do NOT make any manual code fixes between Layer 1 and Layer 2. Manual fixes between layers corrupt pipeline quality measurement. If Layer 1 output has issues, fix the script — not the output.
Layer 1 — Automated Script (migration-toolkit/scripts/bwfc-migrate.ps1):
Run via:
.\migration-toolkit\scripts\bwfc-migrate.ps1 -Path "<source-webforms-project>" -Output "<blazor-output-dir>"Script handles:
asp: prefix stripping (preserves BWFC tags)LoginView injects AuthenticationStateProvider natively and uses the same template names (AnonymousTemplate, LoggedInTemplate). The migration script handles this automatically.Layer 2 — Copilot-Assisted (NOT manual — guided by the bwfc-migration skill):
SelectMethod string → SelectHandler delegate, or Items via OnInitializedAsync)Context="Item")Response.Redirect → NavigationManager.NavigateTo)@* Web Forms *@
<asp:ListView ID="productList" runat="server"
DataKeyNames="ProductID" GroupItemCount="4"
ItemType="WingtipToys.Models.Product"
SelectMethod="GetProducts">
<ItemTemplate>
<td><%#: Item.ProductName %></td>
</ItemTemplate>
</asp:ListView>
@* After migration — Option A: SelectMethod preserved as delegate (BWFC native) *@
<ListView SelectMethod="@productService.GetProducts" GroupItemCount="4">
<ItemTemplate>
<td>@context.ProductName</td>
</ItemTemplate>
</ListView>
@code {
[Inject] private ProductService productService { get; set; }
}@* After migration — Option B: Items loaded in OnInitializedAsync *@
<ListView Items="@_products" GroupItemCount="4">
<ItemTemplate>
<td>@context.ProductName</td>
</ItemTemplate>
</ListView>
@code {
[Inject] private ProductContext Db { get; set; }
private List<Product> _products;
protected override async Task OnInitializedAsync()
{
_products = await Db.Products.ToListAsync();
}
}@* Web Forms *@
<asp:Button ID="btnRemove" runat="server" Text="Remove"
OnClick="RemoveItem_Click" CommandArgument='<%# Item.ItemId %>' />
@* After migration (BWFC preserved) *@
<Button Text="Remove"
OnClick="RemoveItem_Click" CommandArgument="@context.ItemId" />
@code {
// Only signature changes — method name stays the same
private async Task RemoveItem_Click(MouseEventArgs e) { ... }
}@* WRONG — loses all BWFC functionality *@
@foreach (var product in _products)
{
<tr>
<td>@product.ProductName</td>
</tr>
}
@* RIGHT — use BWFC ListView *@
<ListView Items="@_products">
<ItemTemplate>
<tr><td>@context.ProductName</td></tr>
</ItemTemplate>
</ListView>@* WRONG — strips the handler, requires manual re-wiring *@
<Button Text="Submit" />
@* TODO: re-add click handler *@
@* RIGHT — preserve the attribute, only annotate signature change *@
<Button Text="Submit" OnClick="Submit_Click" />
@* TODO: Update Submit_Click signature: (object, EventArgs) → (MouseEventArgs) *@// WRONG — Web Forms base class
public partial class ProductList : Page { }
// RIGHT — BWFC page base class (provides Page.Title, IsPostBack, etc.)
// Set via @inherits WebFormsPageBase in _Imports.razor
public partial class ProductList : WebFormsPageBase { }
// ALSO RIGHT — for non-page components
public partial class MyComponent : ComponentBase { }9bf8669
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.