CtrlK
BlogDocsLog inGet started
Tessl Logo

bwfc-migration

Migrate ASP.NET Web Forms applications to Blazor Server using the webforms-to-blazor CLI tool and BlazorWebFormsComponents (BWFC). Orchestrates L1 automated transforms via CLI, then guides L2 contextual transforms. WHEN: "migrate aspx", "convert web forms", "web forms to blazor", "run migration". INVOKES: webforms-to-blazor CLI tool. FOR SINGLE OPERATIONS: use /bwfc-identity-migration for auth, /bwfc-data-migration for EF/architecture.

79

1.23x
Quality

81%

Does it follow best practices?

Impact

63%

1.23x

Average score across 3 eval scenarios

SecuritybySnyk

Advisory

Suggest reviewing before use

SKILL.md
Quality
Evals
Security

Web Forms → Blazor Migration with BWFC

Overview

This skill orchestrates the full migration from ASP.NET Web Forms to Blazor Server using a three-layer architecture:

LayerExecutorCoverageDescription
L1: Deterministicwebforms-to-blazor CLI tool~70%27 compiled transforms (16 markup + 11 code-behind), project scaffolding, config migration
L2: ContextualCopilot (this skill)~15–20%TODO-driven transforms requiring semantic understanding — session state, lifecycle, data binding
L3: ArchitecturalDeveloper~10–15%Business logic, custom controls, auth flows, architectural decisions

The CLI tool emits structured // TODO(bwfc-*) comments and a JSON migration report. L2 reads that report and applies contextual transforms per TODO category.

Three-layer migration architecture:


Prerequisites

  • .NET 10 SDK or later
  • Install the global tool:
    dotnet tool install -g Fritz.WebFormsToBlazor
  • BlazorWebFormsComponents NuGet package (added automatically by the tool's scaffolding)

Critical Rules

🚨 CRITICAL: USE THE SHIMS — PRIMARY MIGRATION STRATEGY 🚨

ALWAYS inherit from WebFormsPageBase (via _Imports.razor) and use the Web Forms shims. The BlazorWebFormsComponents library provides shims that make Web Forms patterns work AS-IS in Blazor — no manual rewrites needed.

⛔ CRITICAL DATA-CONTROL RULE

  • NEVER replace <asp:ListView>, <asp:FormView>, <asp:GridView>, <asp:DataList>, or <asp:Repeater> with manual HTML, hand-built <table> markup, or @foreach loops.
  • ALWAYS migrate these controls to the BWFC component of the same name: <ListView>, <FormView>, <GridView>, <DataList>, <Repeater>.
  • These data-bound BWFC components already exist and are the correct migration target.
  • If generated BWFC markup is malformed or does not compile, repair the BWFC markup. Do not flatten the control into manual HTML.

Web Forms Patterns That Work Via Shims

Web Forms PatternShimWorks In Interactive Mode?Notes
Response.Redirect("url")ResponseShim✅ YesUses NavigationManager internally, strips ~/ and .aspx
Request.QueryString["key"]RequestShim✅ YesParses from NavigationManager.Uri
Request.Cookies["key"]RequestShim⚠️ SSR onlyReturns empty in interactive, logs warning
Request.Form["key"]FormShim✅ YesVia WebFormsForm component in interactive mode
Session["key"] get/setSessionShim✅ YesIn-memory ConcurrentDictionary per circuit
Session.Get<T>("key")SessionShim✅ YesStrongly-typed session access
Server.MapPath("~/path")ServerShim✅ YesMaps to web root path
Server.HtmlEncode(text)ServerShim✅ YesHTML encoding helper
Cache["key"] get/setCacheShim✅ YesBacked by IMemoryCache
Page.TitleWebFormsPageBase✅ YesSets page title
Page.IsPostBackWebFormsPageBase✅ YesAlways false in Blazor (no postbacks)
ClientScript.RegisterStartupScript()ClientScriptShim✅ YesInjects JavaScript via JSRuntime
ViewState["key"]WebFormsPageBase✅ YesIn-memory dictionary per component instance

⚠️ Server Methods WITHOUT Shims

These Server.* methods have no BWFC shim and require manual rewriting:

Web Forms PatternShim?Migration Action
Server.Transfer("page.aspx")❌ NoneReplace with NavigationManager.NavigateTo(). Server.Transfer does server-side URL rewriting which doesn't exist in Blazor.
Server.GetLastError()❌ NoneUse ILogger and middleware-based error handling (app.UseExceptionHandler).
Server.ClearError()❌ NoneError clearing is handled by middleware in ASP.NET Core.
HttpContext.Current.Session["key"]❌ NoneReplace with Session["key"] (on pages) or inject SessionShim via constructor DI (non-page classes). The CLI tool handles this automatically.

⚠️ Non-Page Classes

Classes that use Session["key"], Response.Redirect(), etc. but do NOT inherit from WebFormsPageBase must receive shims via constructor DI, not the base class:

// Non-page class — inject shims via DI
public class CartHelper
{
    private readonly SessionShim _session;
    public CartHelper(SessionShim session) => _session = session;
    public string GetCartId() => _session["CartId"]?.ToString();
}

⚠️ ThreadAbortException Dead Code

Web Forms throws ThreadAbortException when Response.Redirect(url, true) is called with endResponse=true. Blazor does not throw this exception. Any catch (ThreadAbortException) blocks become dead code after migration — review and remove them.

Key Benefits of Shims

  1. Minimal Code Changes — Original Web Forms code works with ZERO changes in most cases
  2. Compile-Time Safety — Shims provide the same APIs, so existing code compiles unchanged
  3. Interactive Mode Support — Most shims work in both SSR and Interactive render modes
  4. Drop-In Replacementbuilder.Services.AddBlazorWebFormsComponents() registers all shims automatically

When Shims Are Available via WebFormsPageBase

The _Imports.razor file includes @inherits BlazorWebFormsComponents.WebFormsPageBase, which gives EVERY migrated page access to:

// Available on ALL pages via WebFormsPageBase:
Response.Redirect("/Products");           // ✅ Works
Session["CartId"] = 123;                  // ✅ Works
var param = Request.QueryString["id"];    // ✅ Works
var path = Server.MapPath("~/images");    // ✅ Works
Cache["Products"] = productList;          // ✅ Works
ViewState["SortColumn"] = "Name";         // ✅ Works
ClientScript.RegisterStartupScript(...);  // ✅ Works

NO INJECTION NEEDED. These properties are available directly in your @code block.


❌ ANTI-PATTERNS: DO NOT DO THESE

These are WRONG approaches that waste time. The shims already handle these patterns correctly.

❌ Do NOT Inject IHttpContextAccessor

// ❌ WRONG — Fighting Blazor's architecture
[Inject] IHttpContextAccessor HttpContextAccessor { get; set; }

var cookies = HttpContextAccessor.HttpContext?.Request.Cookies;

✅ CORRECT — Use the RequestShim:

// ✅ Inherits WebFormsPageBase via _Imports.razor
var cookieValue = Request.Cookies["MyCookie"];

❌ Do NOT Inject NavigationManager for Redirects

// ❌ WRONG — Manual URL manipulation
[Inject] NavigationManager NavigationManager { get; set; }

NavigationManager.NavigateTo("/Products");

✅ CORRECT — Use the ResponseShim:

// ✅ Works exactly like Web Forms
Response.Redirect("~/Products.aspx");  // Strips ~/ and .aspx automatically

❌ Do NOT Use HttpContext.Response.Cookies Directly

// ❌ WRONG — Only works in SSR, breaks in interactive mode
HttpContext.Response.Cookies.Append("CartId", cartId);

✅ CORRECT — Use SessionShim instead:

// ✅ Works in both SSR and interactive modes
Session["CartId"] = cartId;

❌ Do NOT Create Minimal API Endpoints for Actions

// ❌ WRONG — Unnecessary ASP.NET Core endpoints
app.MapPost("/api/AddToCart", async (CartService cart, int productId) => 
{
    await cart.AddItemAsync(productId);
    return Results.Ok();
});

✅ CORRECT — Keep as Blazor page/component methods:

// ✅ Original Web Forms pattern preserved
private async Task AddToCart_Click()
{
    Session["CartId"] = await _cartService.AddItemAsync(productId);
}

❌ Do NOT Use [ExcludeFromInteractiveRouting] Unless Necessary

// ❌ WRONG — Forces SSR-only when shims handle interactive mode
@attribute [ExcludeFromInteractiveRouting]

✅ CORRECT — Let pages run in interactive mode:

// ✅ Shims work in interactive mode — no attribute needed
@page "/Products"
@inherits WebFormsPageBase

ONLY use [ExcludeFromInteractiveRouting] if:

  • Page genuinely needs HTTP form POST with <form method="post">
  • Page requires server-side cookie manipulation
  • Page uses 3rd-party libraries that require HttpContext

❌ Do NOT Manually Manage State Via Cookies

// ❌ WRONG — Reinventing session management
Response.Cookies.Append("CartId", Guid.NewGuid().ToString(), new CookieOptions 
{
    Expires = DateTimeOffset.UtcNow.AddDays(30),
    IsEssential = true
});

✅ CORRECT — If Web Forms used Session, use SessionShim:

// ✅ Original pattern preserved
Session["CartId"] = Guid.NewGuid().ToString();

❌ Do NOT Add onclick="window.location.href=..." Hacks

// ❌ WRONG — JavaScript workarounds for navigation
<Button Text="View Details" 
        OnClientClick="window.location.href='/ProductDetails?id=5'; return false;" />

✅ CORRECT — Use the BWFC Button with ResponseShim:

// ✅ Web Forms pattern works via shim
<Button Text="View Details" OnClick="@ViewDetails_Click" />

@code {
    private void ViewDetails_Click()
    {
        Response.Redirect($"~/ProductDetails.aspx?id={productId}");
    }
}

❌ Do NOT Fight Blazor's Interactive Router

// ❌ WRONG — Trying to force HTTP semantics into Blazor
app.MapFallback("/Products", async context => 
{
    await context.Response.WriteAsync("Use the Blazor router!");
});

✅ CORRECT — Work WITH Blazor using shims:

// ✅ Standard Blazor routing + shims = Web Forms compatibility
@page "/Products"
@inherits WebFormsPageBase

<GridView SelectMethod="GetProducts" />

🌳 Migration Decision Tree

Use this flowchart when encountering Web Forms patterns:

Original code uses Response.Redirect()?
  → Use Response.Redirect() — ResponseShim handles it ✅

Original code uses Session["key"]?
  → Use Session["key"] — SessionShim handles it ✅
  
Original code uses Request.QueryString["key"]?
  → Use Request.QueryString["key"] — RequestShim handles it ✅

Original code uses Request.Cookies["key"]?
  → If page runs in interactive mode: Use Session instead (cookies need SSR)
  → If page can be SSR: Request.Cookies works via RequestShim

Original code uses HttpContext.Current.Session?
  → Replace HttpContext.Current.Session with Session property from WebFormsPageBase ✅

Need form POST data?
  → Wrap form in <WebFormsForm>, use Request.Form["key"] ✅

Original code uses Server.MapPath()?
  → Use Server.MapPath() — ServerShim handles it ✅

Original code uses Cache["key"]?
  → Use Cache["key"] — CacheShim handles it ✅

Original code uses ViewState["key"]?
  → Use ViewState["key"] — WebFormsPageBase provides it ✅
  → Consider refactoring to component fields for clarity

Original code uses ClientScript.RegisterStartupScript()?
  → Use ClientScript.RegisterStartupScript() — ClientScriptShim handles it ✅

Need to inject a service?
  → @inject MyService Service — standard Blazor DI ✅

The Golden Rule: Preserve the Original Pattern

If the original Web Forms code uses Session["CartId"], the migrated code should use Session["CartId"]. The SessionShim makes this work. Don't reinvent the pattern — use the shims.


Migration Workflow

Phase 1: L1 Automated Transforms (CLI)

⚠️ CRITICAL: Always run L1 via the CLI tool. Do NOT apply L1 transforms manually. The tool produces deterministic, testable output. Manual L1 transforms corrupt measurement and miss edge cases.

Full Project Migration

webforms-to-blazor migrate -i ./MyWebFormsApp -o ./MyBlazorApp --report migration-report.json --verbose
OptionDescription
-i, --input <path>Source Web Forms project root (required)
-o, --output <path>Output Blazor project directory (required)
--report <path>Write JSON migration report to file
--report-format <fmt>json (default) or markdown
--skip-scaffoldSkip .csproj, Program.cs, _Imports.razor generation
--dry-runShow transforms without writing files
-v, --verboseDetailed per-file transform log
--overwriteOverwrite existing files in output directory

Single File Conversion

webforms-to-blazor convert -i ./Pages/Products.aspx -o ./Pages/ --overwrite
OptionDescription
-i, --input <file>.aspx, .ascx, or .master file (required)
-o, --output <path>Output directory (default: same directory)
--overwriteOverwrite existing .razor file

What L1 Handles (27 Transforms)

Markup Transforms (16):

#TransformDescription
1PageDirective<%@ Page %>@page "/route" with title extraction
2MasterDirectiveRemove <%@ Master %>, add @inherits LayoutComponentBase
3ControlDirectiveRemove <%@ Control %> directives
4ImportDirective<%@ Import Namespace="X" %>@using X
5RegisterDirectiveRemove <%@ Register %> tag registrations
6ContentWrapperStrip <asp:Content> wrappers, convert HeadContent
7FormWrapper<form runat="server"><div> (preserves id for CSS)
8GetRouteUrlPage.GetRouteUrl()GetRouteUrlHelper.GetRouteUrl()
9Expression<%: %>@(), <%# Item.X %>@context.X, Eval/Bind conversion
10LoginViewStrip attributes, flag RoleGroups for review
11SelectMethodPreserve attribute, add TODO for delegate conversion
12AjaxToolkitPrefixajaxToolkit:XX (runs before asp: prefix)
13AspPrefixasp:XX for all server controls
14AttributeStripRemove runat="server", normalize IDid
15EventWiringOnClick="Handler"OnClick="@Handler"
16UrlReference~/path/path in href, NavigateUrl, ImageUrl

Code-Behind Transforms (11):

#TransformDescription
1UsingStripRemove System.Web.*, Microsoft.AspNet.* usings
2BaseClassStripRemove : Page, : System.Web.UI.Page base classes
3ResponseRedirect⚠️ DEPRECATED — L1 used to transform Response.Redirect()NavigationManager.NavigateTo(), but this is WRONG. L2 should revert to Response.Redirect() and use ResponseShim.
4SessionDetectDetect Session["key"] patterns, inject // TODO(bwfc-session-state) guidance
5ViewStateDetectDetect ViewState["key"] patterns, inject // TODO(bwfc-viewstate) guidance
6IsPostBackUnwrap simple if (!IsPostBack) guards; TODO complex guards with else
7PageLifecyclePage_LoadOnInitializedAsync, Page_InitOnInitialized, Page_PreRenderOnAfterRenderAsync
8EventHandlerSignatureStrip (object sender, EventArgs e) from standard handlers
9DataBindCross-file: ctrl.DataSource = x → field assignment, inject Items= in markup
10UrlCleanup"~/Products.aspx?id=5""/Products?id=5" in string literals
11AttributeNormalizeBoolean, enum, and unit value normalization

Scaffolding:

  • .csproj with BWFC NuGet reference
  • Program.cs with AddBlazorWebFormsComponents()registers ALL shims automatically (SessionShim, ResponseShim, RequestShim, ServerShim, CacheShim, ClientScriptShim, FormShim)
  • _Imports.razor with BWFC usings and @inherits WebFormsPageBasegives EVERY page access to Session, Response, Request, Server, Cache, ClientScript, ViewState, IsPostBack properties
  • App.razor with InteractiveServer render mode, detected CSS/JS references
  • Routes.razor, GlobalUsings.cs, launchSettings.json
  • appsettings.json from web.config connection strings and app settings
  • WebFormsShims.cs, IdentityShims.cs when applicable
  • Copies App_Start/BundleConfig.cs and RouteConfig.cs as no-op shims

🔑 Key Point: The CLI scaffolding sets up the shim infrastructure automatically. You do NOT need to:

  • ❌ Manually register shim services in DI
  • ❌ Add [Inject] attributes for Session, Response, Request, etc.
  • ❌ Create custom services for patterns the shims already handle

Reading the Migration Report

The --report flag generates a JSON file that drives L2 decisions:

{
  "summary": {
    "filesProcessed": 24,
    "transformsApplied": 187,
    "todosGenerated": 12,
    "scaffoldFilesCreated": 8
  },
  "todos": [
    {
      "category": "bwfc-session-state",
      "file": "Cart.razor.cs",
      "line": 15,
      "message": "Session[\"CartId\"] detected — convert to scoped service",
      "severity": "warning"
    },
    {
      "category": "bwfc-identity-migration",
      "file": "Login.razor.cs",
      "line": 8,
      "message": "FormsAuthentication.SignOut() → SignInManager.SignOutAsync()",
      "severity": "warning"
    }
  ],
  "transforms": [ ... ],
  "scaffolding": { ... }
}

TODO categories map directly to L2 sections below:

  • bwfc-session-state → Session shim wiring
  • bwfc-identity-migration → Auth conversion (delegate to /bwfc-identity-migration)
  • bwfc-data-migration → DataSource → service conversion (delegate to /bwfc-data-migration)
  • bwfc-viewstate → ViewState replacement
  • bwfc-page-lifecycle → Complex lifecycle patterns L1 couldn't auto-convert
  • bwfc-manual → Items requiring developer decision

Phase 2: L2 Contextual Transforms (Copilot-Assisted)

After L1 completes, read the migration report (migration-report.json). For each TODO category, apply the corresponding transforms below.

⚠️ MANDATORY — READ BEFORE STARTING L2: Open and read all three child documents:

  • CODE-TRANSFORMS.md — Lifecycle mapping, event handlers, data binding, Master Page → Shell
  • CONTROL-REFERENCE.md — 58 BWFC component translation tables
  • AJAX-TOOLKIT.md — Ajax Control Toolkit extender migration (14 components)

🔧 First Step: Revert L1's Response.Redirect Transform

CRITICAL: L1's ResponseRedirect transform is WRONG. It converts Response.Redirect() to NavigationManager.NavigateTo(), which breaks the shim pattern.

L2 must revert this transform:

// L1 output (WRONG):
[Inject] NavigationManager NavigationManager { get; set; }

private void ViewProduct_Click()
{
    NavigationManager.NavigateTo("/Products");
}

// L2 fix (CORRECT):
// Remove the [Inject] NavigationManager line

private void ViewProduct_Click()
{
    Response.Redirect("~/Products.aspx");  // ✅ Shim handles this
}

Search pattern: Look for [Inject] NavigationManager and NavigationManager.NavigateTo() calls that originated from Web Forms Response.Redirect().

Fix:

  1. Remove [Inject] NavigationManager NavigationManager { get; set; }
  2. Replace NavigationManager.NavigateTo("/path") with Response.Redirect("~/path.aspx")
  3. The ResponseShim will strip ~/ and .aspx automatically

TODO(bwfc-session-state)

L1 detects Session["key"] patterns and inserts guidance comments. L2 preserves the original pattern — no code changes needed.

✅ The Original Pattern Works AS-IS:

// Original Web Forms code:
Session["CartId"] = cartId;
var id = Session["CartId"]?.ToString();

// Migrated Blazor code (IDENTICAL):
Session["CartId"] = cartId;
var id = Session["CartId"]?.ToString();

Why this works:

  1. _Imports.razor contains @inherits WebFormsPageBase
  2. WebFormsPageBase provides a Session property backed by SessionShim
  3. AddBlazorWebFormsComponents() in Program.cs registers SessionShim automatically

DO NOT:

  • ❌ Inject IHttpContextAccessor to access HttpContext.Session
  • ❌ Create a custom session service when SessionShim exists
  • ❌ Manually manage session state via cookies
  • ❌ Change Session["key"] to await SessionStorage.GetAsync("key") (different pattern)

DO:

  • ✅ Keep the original Session["key"] code unchanged
  • ✅ Let SessionShim handle the storage (in-memory per circuit)
  • ✅ Use Session.Get<T>("key") for strongly-typed access if desired

Note: SessionShim is an in-memory per-circuit store. It does NOT persist across browser refreshes. For durable state, migrate to a scoped DI service with server-side persistence.

For non-page components that need session access, inject SessionShim directly:

@inject SessionShim Session

@code {
    protected override void OnInitialized()
    {
        var cartId = Session["CartId"]?.ToString();  // ✅ Same pattern
    }
}

TODO(bwfc-identity-migration)

L1 detects FormsAuthentication.*, Membership.*, and Roles.* calls. These require deep auth migration.

Quick patterns:

// Before (L1 output with TODO):
// TODO(bwfc-identity-migration): FormsAuthentication.SignOut() → SignInManager.SignOutAsync()
FormsAuthentication.SignOut();

// After (L2):
await SignInManager.SignOutAsync();

For full auth migration, invoke the /bwfc-identity-migration skill — it handles ASP.NET Membership → ASP.NET Core Identity conversion, including database schema migration, cookie configuration, and role-based authorization.

TODO(bwfc-data-migration)

L1 removes DataSourceID attributes from data-bound controls and replaces <asp:SqlDataSource>, <asp:ObjectDataSource>, and <asp:EntityDataSource> controls with TODO comments.

Pattern — SqlDataSource → injected service:

// Before (Web Forms):
// <asp:SqlDataSource ID="ProductsDS" SelectCommand="SELECT * FROM Products" />
// <asp:GridView DataSourceID="ProductsDS" />

// After L1:
// TODO(bwfc-data-migration): Replace SqlDataSource "ProductsDS" with injected service
// <GridView />

// After L2:
@inject ProductService ProductService

<GridView ItemType="Product" SelectMethod="@ProductService.GetProducts" />

For full data migration, invoke the /bwfc-data-migration skill — it handles EF6 → EF Core conversion, service extraction, and repository patterns.

TODO(bwfc-viewstate)

L1 detects ViewState["key"] access patterns but cannot determine replacement strategy without context.

Pattern — simple value storage → component field:

// Before (L1 output with TODO):
// TODO(bwfc-viewstate): ViewState["SortColumn"] detected — replace with component field or parameter
ViewState["SortColumn"] = "Name";
var sort = ViewState["SortColumn"]?.ToString();

// After (L2):
private string _sortColumn = "Name";

Pattern — cross-page state → cascading parameter or query string:

// Before:
// TODO(bwfc-viewstate): ViewState["SelectedId"] detected
ViewState["SelectedId"] = selectedId;

// After (if needed across navigations):
NavigationManager.NavigateTo($"/Details?id={selectedId}");

// Or (if parent-child component communication):
[CascadingParameter] public int SelectedId { get; set; }

Pattern — ViewStateDictionary shim (compile-compatibility bridge):

// For complex ViewState usage that can't be trivially replaced,
// BWFC's ViewStateDictionary provides a per-component dictionary:
// Code-behind that uses ViewState["key"] compiles unchanged via WebFormsPageBase.

TODO(bwfc-page-lifecycle)

L1 auto-converts simple lifecycle methods but flags complex patterns it cannot handle:

Complex IsPostBack guards with else:

// L1 output (flagged, not unwrapped):
// TODO(bwfc-page-lifecycle): IsPostBack guard with else clause — review manually
if (!IsPostBack)
{
    LoadInitialData();
}
else
{
    ProcessPostBackData();
}

// L2 fix: Move 'if' body to OnInitializedAsync, 'else' body to event handlers
protected override async Task OnInitializedAsync()
{
    await LoadInitialDataAsync();
}

// ProcessPostBackData() logic moves to the specific event handler that triggers it

Page_Load with async operations:

// L1 converts signature but can't determine async boundaries:
protected override async Task OnInitializedAsync()
{
    products = GetProducts();  // TODO(bwfc-page-lifecycle): consider making async
}

// L2 fix:
protected override async Task OnInitializedAsync()
{
    products = await GetProductsAsync();
}

Page_PreRender patterns:

// L1 converts to OnAfterRenderAsync but complex logic needs review:
protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (firstRender)
    {
        lblCount.Text = products.Count.ToString();
        StateHasChanged();  // Required — OnAfterRenderAsync runs AFTER render
    }
}

TODO(bwfc-manual)

Items requiring developer decision. Document these and move on:

  • Custom HttpModule / HttpHandler implementations
  • Complex Page_Error / Application_Error patterns
  • Dynamic control creation (Controls.Add(new TextBox()))
  • Literal.Mode = LiteralMode.PassThrough with raw HTML injection
  • Custom WebPart or WebPartZone usage
  • Third-party control libraries not covered by BWFC

Action: Create a MIGRATION-NOTES.md file documenting each manual item with context and recommended approach.

Data Binding Transforms (applies to all migrated files)

⚠️ MANDATORY: SelectMethod MUST be preserved as a delegate. Do NOT convert to Items= binding — this is the #1 recurring migration error.

// Before (Web Forms): SelectMethod="GetProducts"
// After (L2): SelectMethod="@productService.GetProducts"
// BWFC's DataBoundComponent.OnAfterRenderAsync calls the delegate to populate Items.

Full L2 checklist for each file:

  • Convert SelectMethod string → SelectHandler<ItemType> delegate reference
  • Preserve ItemType attribute (strip namespace prefix only)
  • Add Context="Item" to <ItemTemplate> elements
  • Ensure null-safe collection access for Items: Items="@(_products ?? new())"
  • When SelectMethod is set, Items is auto-populated — do NOT also set Items
  • Add @inject directives for required services (NavigationManager, DbContext, etc.)

Phase 3: Build & Verify

cd MyBlazorApp
dotnet build

Common build errors and fixes:

ErrorCauseFix
CS0246: 'Page' could not be foundMissing @inherits WebFormsPageBaseVerify _Imports.razor has @inherits BlazorWebFormsComponents.WebFormsPageBase
CS0103: 'Session' does not existNon-page component using SessionAdd @inject SessionShim Session
CS0103: 'Response' does not existCode-behind using Response.RedirectL1 should have converted; check for missed patterns
CS1061: 'X' does not contain 'DataBind'Explicit .DataBind() calls remainingRemove — BWFC auto-binds via SelectMethod or Items
CS0234: 'Web' does not exist in 'System'Remaining System.Web.* usingRemove unless it's a BWFC shim namespace (System.Web.Optimization, System.Web.Routing)
RZ9986: Component attributes do not support complex contentExpression in attribute without @()Wrap with @(): Value="@(expr)"

Phase 4: L3 Developer Tasks

These require human judgment and cannot be automated:

  • Custom controls — Third-party or custom WebControl / CompositeControl subclasses need manual Blazor component creation
  • Business logic review — Verify migrated BLL/DAL behaves correctly with async patterns
  • Authentication flows — Full auth migration via /bwfc-identity-migration
  • Data architecture — EF6 → EF Core via /bwfc-data-migration
  • Performance tuningStateHasChanged() call optimization, virtualization for large lists
  • Integration testing — Verify form submissions, navigation, data operations end-to-end

BWFC Configuration Reference

Project Setup (scaffolded by L1)

_Imports.razor:

@using BlazorWebFormsComponents
@using BlazorWebFormsComponents.Enums
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@inherits BlazorWebFormsComponents.WebFormsPageBase

The @inherits line gives every page Page.Title, Page.MetaDescription, IsPostBack, Session, Server, Response, Request, Cache, ViewState, ClientScript, PostBack event, ResolveUrl(), and GetRouteUrl() — so Web Forms code-behind compiles unchanged.

Note: @rendermode InteractiveServer is a directive attribute for component instances, NOT a standalone line in _Imports.razor.

Program.cs:

builder.Services.AddBlazorWebFormsComponents();

var app = builder.Build();
app.UseConfigurationManagerShim();

App.razor — render mode and BWFC script:

<HeadOutlet @rendermode="InteractiveServer" />
<Routes @rendermode="InteractiveServer" />
<script src="_content/Fritz.BlazorWebFormsComponents/js/Basepage.js"></script>

Layout (MainLayout.razor):

@inherits LayoutComponentBase

<BlazorWebFormsComponents.Page />

<header><!-- ... --></header>
<main>@Body</main>

Important: WebFormsPageBase provides the code-behind API. The <BlazorWebFormsComponents.Page /> component renders <PageTitle> and <meta> tags. Both are required.

Available Shims

ShimWeb Forms APIBlazor ImplementationSetup
ConfigurationManagerConfigurationManager.AppSettings["key"], .ConnectionStrings["name"]Reads from IConfigurationapp.UseConfigurationManagerShim()
SessionShimSession["key"] indexer, .Get<T>(), .Remove(), .Clear(), .ContainsKey()In-memory per-circuit + optional ISession syncAuto-registered by AddBlazorWebFormsComponents()
ServerShimServer.MapPath(), Server.HtmlEncode(), Server.HtmlDecode(), Server.UrlEncode(), Server.UrlDecode()Wraps IWebHostEnvironment + WebUtilityAuto-registered by AddBlazorWebFormsComponents()
CacheShimCache["key"] indexer, Cache.Insert(), Cache.Get<T>(), Cache.Remove()Wraps IMemoryCache with absolute/sliding expirationAuto-registered by AddBlazorWebFormsComponents()
ResponseShimResponse.Redirect(), Response.CookiesWraps NavigationManager + HttpContext; auto-strips ~/ and .aspxVia WebFormsPageBase.Response
RequestShimRequest.QueryString, Request.Cookies, Request.Url, Request.FormWraps NavigationManager + HttpContext; Form via FormShimVia WebFormsPageBase.Request
FormShimRequest.Form["key"], .GetValues(), .AllKeys, .Count, .ContainsKey()Wraps IFormCollection (SSR) or JS interop data (interactive)Via RequestShim.Form — populated by <WebFormsForm>
ClientScriptShimPage.ClientScript.RegisterStartupScript(), .RegisterClientScriptBlock(), .RegisterClientScriptInclude(), .GetPostBackEventReference()Queues scripts, flushes via IJSRuntime in OnAfterRenderAsyncAuto-registered by AddBlazorWebFormsComponents()
ScriptManagerShimScriptManager.GetCurrent(page), .RegisterStartupScript(), .RegisterClientScriptBlock(), .RegisterClientScriptInclude()Delegates to ClientScriptShimAuto-registered by AddBlazorWebFormsComponents()
ViewStateDictionaryViewState["key"] indexerPer-component in-memory dictionaryVia WebFormsPageBase.ViewState
BundleConfig/RouteConfigBundleTable.Bundles.Add(), RouteTable.Routes.MapPageRoute()No-op stubsCompile-only — no setup needed

WebFormsForm Component (Form POST Migration)

The <WebFormsForm> component enables Request.Form["key"] access in interactive Blazor Server mode where HttpContext and IFormCollection are unavailable. It captures form data via JS interop and feeds it to RequestShim.Form.

Before (Web Forms):

<form runat="server">
    <asp:TextBox ID="txtName" runat="server" />
    <asp:Button Text="Submit" OnClick="Submit_Click" runat="server" />
</form>

// Code-behind:
protected void Submit_Click(object sender, EventArgs e)
{
    var name = Request.Form["txtName"];
}

After (Blazor with BWFC):

<WebFormsForm OnSubmit="SetRequestFormData">
    <TextBox @bind-Text="name" />
    <Button Text="Submit" OnClick="Submit_Click" />
</WebFormsForm>

@code {
    private string name;

    private void Submit_Click()
    {
        // Request.Form["txtName"] works via FormShim
        var formName = Request.Form["txtName"];
    }
}

Key points:

  • <WebFormsForm> renders a standard <form> element
  • In interactive mode, OnSubmit captures form data via JS interop and populates Request.Form
  • Bind OnSubmit="SetRequestFormData" to auto-wire form data into WebFormsPageBase.Request.Form
  • Supports Method (Get/Post) and Action parameters
  • SSR mode uses native IFormCollection — no JS interop needed

When to use <WebFormsForm> vs native Blazor forms:

  • Use <WebFormsForm> when migrated code-behind accesses Request.Form["key"] directly
  • Use <EditForm> for new Blazor forms with model binding
  • Use <form method="post" action="/endpoint"> for auth operations (see identity migration skill)

ClientScript Migration (Shim-Based)

ClientScriptShim provides a compile-compatible bridge for Page.ClientScript patterns. It queues scripts during the component lifecycle and flushes them via IJSRuntime after render.

Before (Web Forms):

Page.ClientScript.RegisterStartupScript(GetType(), "init",
    "alert('Page loaded!');", addScriptTags: true);

Page.ClientScript.RegisterClientScriptInclude("jquery",
    "~/Scripts/jquery.min.js");

if (!Page.ClientScript.IsStartupScriptRegistered(GetType(), "init"))
{
    Page.ClientScript.RegisterStartupScript(GetType(), "init", "doInit();", true);
}

After (Blazor with BWFC — via WebFormsPageBase.ClientScript):

// Code-behind compiles unchanged — ClientScript is a property on WebFormsPageBase
ClientScript.RegisterStartupScript(GetType(), "init",
    "alert('Page loaded!');", addScriptTags: true);

ClientScript.RegisterClientScriptInclude("jquery",
    "/Scripts/jquery.min.js");

if (!ClientScript.IsStartupScriptRegistered(GetType(), "init"))
{
    ClientScript.RegisterStartupScript(GetType(), "init", "doInit();", true);
}

ScriptManager code-behind also works:

// Before (Web Forms):
var sm = ScriptManager.GetCurrent(this.Page);
sm.RegisterStartupScript(this, GetType(), "key", "doWork();", true);

// After (Blazor — via ScriptManagerShim):
var sm = ScriptManagerShim.GetCurrent(this);
sm.RegisterStartupScript(this, GetType(), "key", "doWork();", true);

When to use shim vs. native IJSRuntime:

  • Use shim for Phase 1 migration — existing Page.ClientScript code compiles unchanged
  • Use IJSRuntime for new Blazor code or Phase 3 cleanup — cleaner, more idiomatic
  • The shim internally uses IJSRuntime — no performance difference

PostBack Event Handling

WebFormsPageBase provides PostBack compatibility via JS interop. The __doPostBack() JavaScript function is auto-bootstrapped and routes events back to the Blazor component.

Before (Web Forms):

// IPostBackEventHandler implementation
public void RaisePostBackEvent(string eventArgument)
{
    // Handle postback with argument
    ProcessAction(eventArgument);
}

// Client-side trigger
Page.ClientScript.GetPostBackEventReference(this, "delete:42");

After (Blazor with BWFC):

@inherits WebFormsPageBase

@code {
    protected override void OnInitialized()
    {
        PostBack += OnPostBack;
    }

    private void OnPostBack(object sender, PostBackEventArgs e)
    {
        // e.EventTarget = control ID, e.EventArgument = "delete:42"
        ProcessAction(e.EventArgument);
    }
}

PostBack API surface on WebFormsPageBase:

  • event EventHandler<PostBackEventArgs> PostBack — raised when __doPostBack() fires
  • ClientScript.GetPostBackEventReference(control, argument) — returns JS expression string
  • ClientScript.GetPostBackClientHyperlink(control, argument) — returns javascript:__doPostBack(...) URL
  • ClientScript.GetCallbackEventReference(...) — returns __bwfc_callback(...) expression
  • HandlePostBackFromJs(eventTarget, eventArgument)[JSInvokable] bridge method
  • HandleCallbackFromJs(eventTarget, eventArgument)[JSInvokable] callback bridge (override in derived pages)

appsettings.json mapping (from web.config):

{
  "AppSettings": {
    "SiteName": "My Store",
    "ItemsPerPage": "20"
  },
  "ConnectionStrings": {
    "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=MyDb;Trusted_Connection=True;"
  }
}

Component Reference

See CONTROL-REFERENCE.md for the full translation table of 58 BWFC components across 6 categories.


Common Patterns

Expression Conversion

Web Forms ExpressionBlazor EquivalentNotes
<%: expression %>@(expression)HTML-encoded output
<%= expression %>@(expression)Blazor always encodes
<%# Item.Property %>@context.PropertyInside data-bound templates
<%#: Item.Property %>@context.PropertySame — Blazor always encodes
<%# Eval("Property") %>@context.PropertyDirect property access
<%# Bind("Property") %>@bind-Value="context.Property"Two-way binding
<%$ RouteValue:id %>@Id (with [Parameter])Route parameters
<%-- comment --%>@* comment *@Razor comments
<% if (cond) { %>@if (cond) {Control flow
<% foreach (var x in items) { %>@foreach (var x in items) {Loops

File Conversion

Web FormsBlazor
MyPage.aspx + .aspx.csMyPage.razor + .razor.cs
MyControl.ascx + .ascx.csMyControl.razor + .razor.cs
Site.Master + .Master.csMainLayout.razor + .razor.cs

Directive Conversion

Web Forms DirectiveBlazor Equivalent
<%@ Page Title="X" ... %>@page "/route"
<%@ Master ... %>(remove — layouts don't need directives)
<%@ Control ... %>(remove — components don't need directives)
<%@ Register TagPrefix="uc" Src="~/X.ascx" %>@using MyApp.Components
<%@ Import Namespace="X" %>@using X

Drop entirely: AutoEventWireup, CodeBehind, Inherits, EnableViewState, MasterPageFile, ValidateRequest, ClientIDMode, EnableTheming, SkinID

Content/Layout Conversion

Web FormsBlazor
<asp:Content ContentPlaceHolderID="MainContent"><Content ContentPlaceHolderID="MainContent"> inside <ChildComponents>
<asp:Content ContentPlaceHolderID="HeadContent">Prefer page-level <HeadContent> or shell <Head> depending on ownership
<asp:ContentPlaceHolder ID="MainContent" /><ContentPlaceHolder ID="MainContent" /> inside <ChildContent>

Route URL Conversion

Web FormsBlazor
href="~/Products"href="/Products"
NavigateUrl="~/Products/<%: Item.ID %>"NavigateUrl="@($"/Products/{context.ID}")"
GetRouteUrl("Route", new { id = Item.ID })@($"/Products/{context.ID}") or GetRouteUrlHelper
Response.Redirect("~/Products")NavigationManager.NavigateTo("/Products")

Master Page → BWFC Shell

@* Before: <%@ Master Language="C#" CodeBehind="Site.master.cs" %> *@
@* After: *@
<MasterPage>
    <Head>
        <title>@(Page.Title)</title>
    </Head>
    <ChildContent>
        <header>
            <nav><Menu ... /></nav>
        </header>
        <main>
            <ContentPlaceHolder ID="MainContent" />
        </main>
        <footer>© @DateTime.Now.Year</footer>

        @ChildContent
    </ChildContent>
</MasterPage>

@code {
    [Parameter]
    public RenderFragment? ChildContent { get; set; }
}

Key changes:

  • <form runat="server"> → removed from the shell wrapper
  • <asp:ContentPlaceHolder ID="MainContent"><ContentPlaceHolder ID="MainContent">
  • <asp:ScriptManager><ScriptManager /> (renders nothing)
  • CSS/meta/title from master <head> → shell <Head> content
  • Child-page content sections should live under <ChildComponents>

Tip: Collapse to native @layout + @Body only after the migrated shell truly behaves like a single-slot layout. Until then, keep the BWFC shell contract intact.


Reference Documents

  • CONTROL-REFERENCE.md — 58 component translation tables, structural components, theming, custom control base classes
  • CODE-TRANSFORMS.md — Lifecycle mapping, event handlers, data binding, navigation, Master Page → Shell
  • AJAX-TOOLKIT.md — Ajax Control Toolkit extender migration (14 components)

Common Gotchas

No ViewState

Replace ViewState["key"] with component fields. ViewStateDictionary shim available for compile-compat.

PostBack Compatibility

WebFormsPageBase.IsPostBack works correctly: returns false for SSR GET / interactive first render, true for SSR POST / interactive subsequent renders. L1 auto-unwraps simple if (!IsPostBack) guards. Complex guards (with else) get TODO comments. For __doPostBack() JavaScript patterns, subscribe to the PostBack event on WebFormsPageBase — see PostBack Event Handling above.

No DataSource Controls

SqlDataSource, ObjectDataSource, EntityDataSource → injected services. See /bwfc-data-migration.

ID Rendering

Blazor doesn't render component IDs. Use CssClass or explicit id attributes for CSS/JS targeting.

Template Context Variable

Add Context="Item" on template elements:

<ItemTemplate Context="Item">
    @Item.PropertyName
</ItemTemplate>

Event Handler Signatures

// Web Forms: protected void Btn_Click(object sender, EventArgs e) { }
// Blazor:    private void Btn_Click() { }

L1 auto-strips standard EventArgs. Specialized types (CommandEventArgs, etc.) are preserved.

TextMode="MultiLine" Casing

BWFC uses Multiline (lowercase 'l'), not MultiLine. Silent failure if wrong.

ScriptManager/ScriptManagerProxy

ScriptManager and ScriptManagerProxy Razor components are no-op stubs (render nothing). For code-behind patterns like ScriptManager.GetCurrent(page).RegisterStartupScript(...), use ScriptManagerShim.GetCurrent(this) which delegates to ClientScriptShim. Include the Razor components during migration to prevent markup errors; remove when stable.

runat="server" on HTML Elements

L1 removes these. Use @ref if programmatic access is needed.


Troubleshooting

L1 Tool Issues

ProblemSolution
webforms-to-blazor not foundRun dotnet tool install -g Fritz.WebFormsToBlazor
Tool version mismatchRun dotnet tool update -g Fritz.WebFormsToBlazor
Output directory not emptyUse --overwrite flag
Need to preview changes firstUse --dry-run flag
Missing scaffolding filesDon't use --skip-scaffold unless you have an existing Blazor project

L2 Common Issues

ProblemSolution
SelectMethod not firingEnsure it's a delegate reference (@service.Method), not a string
Items always emptyCheck that SelectMethod signature matches SelectHandler<T> delegate
Template binding errorsAdd Context="Item" to <ItemTemplate> elements
Session data lost on refreshSessionShim is per-circuit; use persistent storage for critical data
Infinite render loopGuard OnAfterRenderAsync with if (firstRender), call StateHasChanged() only when needed

Per-Page Migration Checklist

## Page: [PageName.aspx] → [PageName.razor]

### L1 — CLI Tool (automated)
- [ ] `webforms-to-blazor migrate` or `convert` executed
- [ ] Migration report reviewed
- [ ] File renamed (.aspx → .razor)
- [ ] Directives converted
- [ ] asp: prefixes removed
- [ ] runat="server" removed
- [ ] Expressions converted
- [ ] URLs converted
- [ ] Content wrappers removed
- [ ] IsPostBack guards unwrapped/TODO'd
- [ ] .aspx URL literals cleaned up

### L2 — Copilot Transforms (per TODO category)
- [ ] TODO(bwfc-session-state) items resolved
- [ ] TODO(bwfc-viewstate) items resolved
- [ ] TODO(bwfc-page-lifecycle) items resolved
- [ ] TODO(bwfc-data-migration) items resolved or delegated
- [ ] TODO(bwfc-identity-migration) items resolved or delegated
- [ ] TODO(bwfc-manual) items documented
- [ ] SelectMethod string → SelectHandler delegate
- [ ] Template Context="Item" verified
- [ ] @inject directives added

### Verification
- [ ] `dotnet build` succeeds
- [ ] Page renders correctly
- [ ] Interactive features work
- [ ] No browser console errors

Error SignatureRecipe File
CS7036: no argument ... 'options' of 'XxxContext'recipes/new-dbcontext-to-di.md
CS0103 on @ref fields, no .razor.csrecipes/missing-code-behind.md
CS1061: 'GridView<T>' ... 'Rows'/'FindControl'recipes/gridview-row-findcontrol.md
CS1061: ... 'InnerText'recipes/innertext-to-markup.md
CS1503: SelectMethod ... 'string' to 'SelectHandler'recipes/selectmethod-string-binding.md
CSS/layout visual regressionrecipes/layout-css-body-class.md
CS1061: 'RequestShim' ... 'IsLocal'recipes/request-shim-gaps.md
CS0103 on OAuth fieldsrecipes/oauth-page-stubs.md
CS0246: 'IDatabaseInitializer'recipes/database-seed-initializer.md
Session.SetString(key, = null) garbled syntaxrecipes/session-transform-garbling.md
Circular DI: class injects itselfrecipes/circular-self-injection.md
CS1503/CS0123: EventCallback signaturerecipes/eventcallback-signature-mismatch.md
CS0542: nested class same name as outerrecipes/nested-class-collision.md
Repository
FritzAndFriends/BlazorWebFormsComponents
Last updated
Created

Is this your skill?

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.