Skip to content

Commit f561482

Browse files
csharpfritzCopilot
andauthored
feat: Request.Form shim for Web Forms form POST migration (#532)
* feat: add Request.Form shim for Web Forms form POST migration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: add Request.Form shim documentation - Add Request.Form section to RequestShim.md with FormShim overview - Document supported members: Form['key'], GetValues(), AllKeys, Count, ContainsKey() - Include Blazor usage example with OnInitialized lifecycle - Add 'Rendering Mode Behavior' table showing SSR vs interactive behavior - Update 'Graceful Degradation' table to include Form column - Enhance 'Migration Path' table with Request.Form and Form.GetValues() rows - Expand 'Moving On' section with form binding and EditForm guidance - Add complete before/after code example showing migration path Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: add Request.Form sample page with migration demos Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: update Beast history with Request.Form shim documentation task Record completion of Request.Form documentation work, including: - FormShim API documentation in RequestShim.md - Supported members table and examples - Graceful degradation patterns (SSR vs interactive) - Migration path guidance toward EditForm + @Bind Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: add bUnit tests for Request.Form shim Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: update Rogue history with Form shim test session Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: update jubilee history with Request.Form sample page learnings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: add Playwright integration tests for Request.Form demo Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: redesign Request.Form demo with working SSR form POST The demo page now uses SSR mode with a real HTML form that submits via HTTP POST. Request.Form values are read and displayed after submission, proving the shim works end-to-end. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: update Playwright tests for SSR form POST demo Replaced interactive-mode tests with SSR-appropriate tests: - Removed __doPostBack waits (SSR pages have no Blazor circuit) - Updated data-audit-control selectors to match redesigned page - Added end-to-end form POST test that fills fields and verifies results - Replaced graceful-degradation test with initial-load-no-results test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: add @formname to SSR form for Blazor form mapping Blazor SSR requires @formname on <form> elements so the middleware can identify which form is being submitted on POST. Without it, the POST is rejected with 'does not specify which form' error. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: use Expect assertion for redirect test to avoid CI timeouts The Redirect_NavigatesToSessionPage test timed out on CI because WaitForURLAsync relies on navigation lifecycle events which are slow when Blazor Server routes through SignalR -> JS interop -> location.href. Switch to Playwright's auto-retrying Expect(page).ToHaveURLAsync which polls the URL property directly, with a 60s timeout for slow CI runners. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f9d249a commit f561482

File tree

14 files changed

+946
-21
lines changed

14 files changed

+946
-21
lines changed

.squad/agents/beast/history.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1250,3 +1250,33 @@ This wave establishes **documentation patterns** that will guide future control
12501250
- Shim philosophy ("zero-rewrite") is the defining characteristic of BWFC; must be stated clearly and repeatedly
12511251
- Real-world example reduces abstract pattern to concrete steps — 6-week timeline with specific pages makes it believable
12521252
- Placement in nav right after "Getting Started" signals that philosophy comes before mechanics; guides readers toward informed decision-making
1253+
1254+
### 2026 (Current): Request.Form Shim Documentation
1255+
1256+
**Task:** Document the new `Request.Form` property added to the RequestShim infrastructure by Cyclops.
1257+
1258+
**What was built (by Cyclops):** FormShim wrapper providing `NameValueCollection`-compatible API for accessing form POST data:
1259+
- `Request.Form["fieldName"]` first value or null
1260+
- `Request.Form.GetValues("fieldName")` string[] for multi-value fields
1261+
- `Request.Form.AllKeys` all field names
1262+
- `Request.Form.Count` field count
1263+
- `Request.Form.ContainsKey("key")` existence check
1264+
- Gracefully returns empty in interactive mode (logs warning once)
1265+
- Catches exceptions for non-form-encoded requests (JSON, etc.)
1266+
1267+
**Documentation Approach:**
1268+
- Located existing `docs/UtilityFeatures/RequestShim.md` already documented QueryString, Cookies, and Url properties
1269+
- Added new `## Request.Form` section with:
1270+
- 1 code example (Web Forms form POST pattern)
1271+
- 5-member supported members table (indexer, GetValues, AllKeys, Count, ContainsKey)
1272+
- Blazor usage example with OnInitialized lifecycle
1273+
- 'Rendering Mode Behavior' table (SSR vs interactive)
1274+
- Updated 'Graceful Degradation' comparison table to include Form column
1275+
- Expanded 'Migration Path' table with 2 new rows (Form["field"] and Form.GetValues())
1276+
- Enhanced 'Moving On' section with step 2 guidance: 'Replace Form with EditForm and @bind'
1277+
- Added complete before/after code example showing migration path
1278+
1279+
**Files Changed:**
1280+
- `docs/UtilityFeatures/RequestShim.md` (+76 lines)
1281+
1282+
**Commit:** `a1646602` to `feature/request-form-shim`

.squad/agents/jubilee/history.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,3 +486,16 @@ This wave establishes **documentation patterns** that will guide future control
486486
- **Updated** `ComponentList.razor` — added PostBack & ScriptManager link in Migration Helpers section.
487487
- **Build verified:** 0 errors, pre-existing BL0005 warnings only.
488488
- **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.
489+
490+
491+
### Request.Form Sample Page (2026-04-07)
492+
493+
- **Created** `RequestFormDemo.razor` at `/migration/request-form` -- four-section demo page for the FormShim.
494+
- **Section 1: Basic Field Access** -- Before/After showing Request.Form["username"]. Live demo displays null in interactive mode.
495+
- **Section 2: Multi-Value Fields** -- GetValues for checkboxes. Live demo shows null return.
496+
- **Section 3: Form Metadata** -- Table with AllKeys, Count, ContainsKey live values.
497+
- **Section 4: Migration Guidance** -- Side-by-side Request.Form shim vs EditForm+bind migration path table.
498+
- **Updated** ComponentCatalog.cs -- added Request.Form in Migration Helpers after Request.
499+
- **data-audit-control** markers: form-basic-demo, form-getvalues-demo, form-metadata-demo, form-migration-guidance.
500+
- **Build verified:** 0 errors.
501+
- **Lesson:** FormShim indexer returns null (not empty string) -- use null-coalescing for display.

.squad/agents/rogue/history.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,18 @@ Test file: `src/BlazorWebFormsComponents.Test/UpdatePanel/ContentTemplateTests.r
345345

346346
**Key patterns:**
347347
- Regex GUID detection: `[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-...` for anti-GUID assertions
348+
349+
## Request.Form Shim Tests (feature/request-form-shim)
350+
351+
Added 12 bUnit tests for `Request.Form` / `FormShim` in `RequestShimTests.razor`. Three categories:
352+
353+
1. **Without HttpContext (5 tests):** Verified graceful degradation — indexer returns null, GetValues returns null, AllKeys empty, Count 0, ContainsKey false.
354+
2. **With HttpContext (6 tests):** SSR mode with `DefaultHttpContext` + `FormCollection` — single values, multi-value fields (checkbox pattern), AllKeys, Count, ContainsKey, and missing key returns null.
355+
3. **Edge case (1 test):** Non-form-encoded request (JSON content-type) catches `InvalidOperationException` and returns empty FormShim.
356+
357+
Created `RenderWithFormHttpContext()` helper to DRY up HttpContext+form setup across SSR tests.
358+
359+
All 20 RequestShimTests passing (8 existing + 12 new). Pushed to `feature/request-form-shim`.
348360
- Label-for accessibility: always verify label.for == input.id
349361
- _Imports.razor provides `@inherits BlazorWebFormsTestContext` — no need for explicit @inherits in test files
350362
- `@using Shouldly` added locally when not using _Imports default assertions

docs/UtilityFeatures/RequestShim.md

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,67 @@ In Blazor, HTTP request data is only available during server-side rendering (SSR
4343
}
4444
```
4545

46+
## Request.Form
47+
48+
The `FormShim` provides `NameValueCollection`-compatible access to HTTP form POST data in server-side rendering (SSR) mode. This allows code-behind using `Request.Form["fieldName"]` to compile and work with Blazor forms.
49+
50+
```csharp
51+
// Web Forms
52+
string username = Request.Form["username"];
53+
string[] selectedColors = Request.Form.GetValues("colors");
54+
if (Request.Form.Count > 0) { /* process form */ }
55+
```
56+
57+
### Supported Members
58+
59+
| Member | Returns | Description |
60+
|--------|---------|-------------|
61+
| `Form["key"]` | `string?` | First value for the field, or null |
62+
| `Form.GetValues("key")` | `string[]?` | All values (multi-select, checkboxes) |
63+
| `Form.AllKeys` | `string[]` | Names of all submitted fields |
64+
| `Form.Count` | `int` | Number of form fields |
65+
| `Form.ContainsKey("key")` | `bool` | Whether a field was submitted |
66+
67+
### Blazor Usage
68+
69+
```razor
70+
@inherits WebFormsPageBase
71+
72+
@code {
73+
private string _username = "";
74+
private string[] _selectedColors = Array.Empty<string>();
75+
private int _fieldCount = 0;
76+
77+
protected override void OnInitialized()
78+
{
79+
base.OnInitialized();
80+
81+
if (Request.Form.Count > 0)
82+
{
83+
_username = Request.Form["username"] ?? "";
84+
_selectedColors = Request.Form.GetValues("colors") ?? Array.Empty<string>();
85+
_fieldCount = Request.Form.Count;
86+
}
87+
}
88+
}
89+
```
90+
91+
### Rendering Mode Behavior
92+
93+
| Mode | Behavior |
94+
|------|----------|
95+
| SSR (Static Server Rendering) | Full access — wraps `HttpContext.Request.Form` |
96+
| Interactive Blazor Server | Returns empty — logs warning on first access |
97+
| Non-form requests (JSON, etc.) | Returns empty — exceptions caught gracefully |
98+
4699
## Graceful Degradation
47100

48101
Blazor Server components can run in two modes:
49102

50-
| Mode | `HttpContext` | `QueryString` | `Cookies` | `Url` |
51-
|---|---|---|---|---|
52-
| SSR / Pre-render | ✅ Available | From HTTP request | From HTTP request | From HTTP request |
53-
| Interactive (WebSocket) | ❌ Unavailable | Parsed from `NavigationManager.Uri` | Empty collection (warning logged) | From `NavigationManager.Uri` |
103+
| Mode | `HttpContext` | `QueryString` | `Cookies` | `Url` | `Form` |
104+
|---|---|---|---|---|---|
105+
| SSR / Pre-render | ✅ Available | From HTTP request | From HTTP request | From HTTP request | From HTTP request |
106+
| Interactive (WebSocket) | ❌ Unavailable | Parsed from `NavigationManager.Uri` | Empty collection (warning logged) | From `NavigationManager.Uri` | Empty collection (warning logged) |
54107

55108
Use the `IsHttpContextAvailable` guard when cookie access is critical:
56109

@@ -130,6 +183,8 @@ string fullPath = Request.Url.AbsolutePath;
130183
|---|---|---|
131184
| `Request.QueryString["id"]` | `Request.QueryString["id"]` | `NavigationManager.Uri` + parse, or `[SupplyParameterFromQuery]` |
132185
| `Request.Cookies["name"]` | `Request.Cookies["name"]` | `IHttpContextAccessor` (SSR only) |
186+
| `Request.Form["field"]` | `Request.Form["field"]` | `EditForm` with `@bind` |
187+
| `Request.Form.GetValues("field")` | `Request.Form.GetValues("field")` | `EditForm` with multi-value binding |
133188
| `Request.Url` | `Request.Url` | `NavigationManager.ToAbsoluteUri(Nav.Uri)` |
134189
| `Request.Url.AbsolutePath` | `Request.Url.AbsolutePath` | `new Uri(Nav.Uri).AbsolutePath` |
135190

@@ -138,22 +193,37 @@ string fullPath = Request.Url.AbsolutePath;
138193
`RequestShim` is a migration bridge. As you refactor:
139194

140195
1. **Replace `QueryString` with `[SupplyParameterFromQuery]`** — Blazor's built-in attribute binds query parameters directly to component properties
141-
2. **Replace `Url` with `NavigationManager`** — Inject `NavigationManager` and use `Uri` or `ToAbsoluteUri()`
142-
3. **Replace `Cookies` with proper state management** — Use cascading parameters, `ProtectedSessionStorage`, or server-side services instead of cookies
196+
2. **Replace `Form` with `EditForm` and `@bind`** — Blazor's form data binding replaces the traditional POST form pattern
197+
3. **Replace `Url` with `NavigationManager`** — Inject `NavigationManager` and use `Uri` or `ToAbsoluteUri()`
198+
4. **Replace `Cookies` with proper state management** — Use cascading parameters, `ProtectedSessionStorage`, or server-side services instead of cookies
143199

144200
```razor
145201
@* Before (migration shim) *@
146202
@inherits WebFormsPageBase
147203
@code {
148204
string id = Request.QueryString["id"];
205+
string username = Request.Form["username"];
149206
Uri url = Request.Url;
150207
}
151208
152209
@* After (native Blazor) *@
153210
@inject NavigationManager Nav
211+
212+
<EditForm Model="@_model" OnSubmit="@HandleSubmit">
213+
<InputText @bind-Value="_model.Username" />
214+
<button type="submit">Submit</button>
215+
</EditForm>
216+
154217
@code {
155218
[SupplyParameterFromQuery] public string Id { get; set; }
156219
Uri url => Nav.ToAbsoluteUri(Nav.Uri);
220+
221+
private FormModel _model = new();
222+
223+
private void HandleSubmit()
224+
{
225+
// Process _model directly — no Request.Form needed
226+
}
157227
}
158228
```
159229

samples/AfterBlazorServerSide.Tests/ControlSampleTests.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,14 @@ public async Task AjaxControl_Loads_WithoutErrors(string path)
247247
await VerifyPageLoadsWithoutErrors(path);
248248
}
249249

250+
// Migration Shim Sample Pages
251+
[Theory]
252+
[InlineData("/migration/request-form")]
253+
public async Task MigrationPage_Loads_WithoutErrors(string path)
254+
{
255+
await VerifyPageLoadsWithoutErrors(path);
256+
}
257+
250258
// Ajax Control Toolkit Controls
251259
[Theory]
252260
[InlineData("/ControlSamples/Accordion")]
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
using Microsoft.Playwright;
2+
3+
namespace AfterBlazorServerSide.Tests.Migration;
4+
5+
/// <summary>
6+
/// Integration tests for the Request.Form shim sample page at /migration/request-form.
7+
/// This page uses SSR mode via [ExcludeFromInteractiveRouting] so traditional form POSTs work.
8+
/// </summary>
9+
[Collection(nameof(PlaywrightCollection))]
10+
public class RequestFormTests
11+
{
12+
private readonly PlaywrightFixture _fixture;
13+
14+
public RequestFormTests(PlaywrightFixture fixture)
15+
{
16+
_fixture = fixture;
17+
}
18+
19+
/// <summary>
20+
/// Smoke test — verifies the SSR page loads without errors and displays the expected heading.
21+
/// </summary>
22+
[Fact]
23+
public async Task RequestForm_PageLoads_Successfully()
24+
{
25+
var page = await _fixture.NewPageAsync();
26+
var consoleErrors = new List<string>();
27+
28+
page.Console += (_, msg) =>
29+
{
30+
if (msg.Type == "error")
31+
{
32+
if (System.Text.RegularExpressions.Regex.IsMatch(msg.Text, @"^\[\d{4}-\d{2}-\d{2}T"))
33+
return;
34+
if (msg.Text.StartsWith("Failed to load resource"))
35+
return;
36+
consoleErrors.Add(msg.Text);
37+
}
38+
};
39+
40+
try
41+
{
42+
var response = await page.GotoAsync($"{_fixture.BaseUrl}/migration/request-form", new PageGotoOptions
43+
{
44+
WaitUntil = WaitUntilState.NetworkIdle,
45+
Timeout = 30000
46+
});
47+
48+
Assert.NotNull(response);
49+
Assert.True(response.Ok, $"Page failed to load with status: {response.Status}");
50+
51+
// Verify the page heading renders
52+
var heading = page.Locator("h2").Filter(new() { HasTextString = "Request.Form Migration" });
53+
await Assertions.Expect(heading).ToBeVisibleAsync();
54+
55+
// Verify page title
56+
await Assertions.Expect(page).ToHaveTitleAsync(new System.Text.RegularExpressions.Regex("Request\\.Form"));
57+
58+
Assert.Empty(consoleErrors);
59+
}
60+
finally
61+
{
62+
await page.CloseAsync();
63+
}
64+
}
65+
66+
/// <summary>
67+
/// Render test — verifies the form input card and migration guidance render on GET.
68+
/// No results card should appear before form submission.
69+
/// </summary>
70+
[Fact]
71+
public async Task RequestForm_InitialLoad_ShowsFormWithoutResults()
72+
{
73+
var page = await _fixture.NewPageAsync();
74+
75+
try
76+
{
77+
await page.GotoAsync($"{_fixture.BaseUrl}/migration/request-form", new PageGotoOptions
78+
{
79+
WaitUntil = WaitUntilState.NetworkIdle,
80+
Timeout = 30000
81+
});
82+
83+
// Form input card should be visible
84+
var formInput = page.Locator("[data-audit-control='form-input']");
85+
await Assertions.Expect(formInput).ToBeVisibleAsync();
86+
87+
// Migration guidance card should be visible
88+
var migrationGuidance = page.Locator("[data-audit-control='form-migration-guidance']");
89+
await Assertions.Expect(migrationGuidance).ToBeVisibleAsync();
90+
await Assertions.Expect(migrationGuidance.Locator(".card-title")).ToContainTextAsync("Migration Path Summary");
91+
92+
// Results card should NOT be visible before form submission
93+
var results = page.Locator("[data-audit-control='form-results']");
94+
await Assertions.Expect(results).ToHaveCountAsync(0);
95+
}
96+
finally
97+
{
98+
await page.CloseAsync();
99+
}
100+
}
101+
102+
/// <summary>
103+
/// End-to-end test — fills the form, submits via HTTP POST, and verifies
104+
/// Request.Form values are read and displayed correctly.
105+
/// </summary>
106+
[Fact]
107+
public async Task RequestForm_FormPost_ShowsSubmittedValues()
108+
{
109+
var page = await _fixture.NewPageAsync();
110+
111+
try
112+
{
113+
await page.GotoAsync($"{_fixture.BaseUrl}/migration/request-form", new PageGotoOptions
114+
{
115+
WaitUntil = WaitUntilState.NetworkIdle,
116+
Timeout = 30000
117+
});
118+
119+
// Fill in the form fields
120+
await page.FillAsync("#username", "TestUser");
121+
await page.FillAsync("#email", "test@example.com");
122+
123+
// Check "Red" and "Blue" color checkboxes
124+
await page.CheckAsync("#colorRed");
125+
await page.CheckAsync("#colorBlue");
126+
127+
// Submit the form — this triggers a real HTTP POST (SSR page)
128+
await page.ClickAsync("button[type='submit']");
129+
130+
// Wait for the page to reload with POST results
131+
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
132+
133+
// Results card should now be visible
134+
var results = page.Locator("[data-audit-control='form-results']");
135+
await Assertions.Expect(results).ToBeVisibleAsync();
136+
137+
// Verify submitted values appear in the results table
138+
var resultsTable = results.Locator("table");
139+
140+
// Username
141+
var usernameRow = resultsTable.Locator("tr").Filter(new() { HasTextString = "Request.Form[\"username\"]" });
142+
await Assertions.Expect(usernameRow.Locator("strong")).ToContainTextAsync("TestUser");
143+
144+
// Email
145+
var emailRow = resultsTable.Locator("tr").Filter(new() { HasTextString = "Request.Form[\"email\"]" });
146+
await Assertions.Expect(emailRow.Locator("strong")).ToContainTextAsync("test@example.com");
147+
148+
// GetValues — should contain Red and Blue
149+
var colorsRow = resultsTable.Locator("tr").Filter(new() { HasTextString = "GetValues" });
150+
await Assertions.Expect(colorsRow.Locator("strong")).ToContainTextAsync("Red");
151+
await Assertions.Expect(colorsRow.Locator("strong")).ToContainTextAsync("Blue");
152+
153+
// Count should be > 0 (at least username, email, colors, plus antiforgery token)
154+
var countRow = resultsTable.Locator("tr").Filter(new() { HasTextString = "Request.Form.Count" });
155+
var countText = await countRow.Locator("strong").TextContentAsync();
156+
Assert.True(int.Parse(countText!) > 0, "Form count should be greater than 0 after POST");
157+
158+
// ContainsKey("username") should be True
159+
var containsRow = resultsTable.Locator("tr").Filter(new() { HasTextString = "ContainsKey" });
160+
await Assertions.Expect(containsRow.Locator("strong")).ToContainTextAsync("True");
161+
162+
// Form fields should be pre-filled with submitted values
163+
var usernameInput = page.Locator("#username");
164+
await Assertions.Expect(usernameInput).ToHaveValueAsync("TestUser");
165+
}
166+
finally
167+
{
168+
await page.CloseAsync();
169+
}
170+
}
171+
}

samples/AfterBlazorServerSide.Tests/Migration/ResponseRedirectTests.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,12 @@ await page.WaitForFunctionAsync("typeof window.__doPostBack === 'function'",
4141

4242
await button.ClickAsync();
4343

44-
// forceLoad: true triggers full page navigation
45-
await page.WaitForURLAsync("**/migration/session", new PageWaitForURLOptions
46-
{
47-
WaitUntil = WaitUntilState.Load,
48-
Timeout = 30000
49-
});
44+
// forceLoad: true goes through SignalR → JS interop → location.href,
45+
// which can be slow on CI. Use auto-retrying Expect assertion instead
46+
// of WaitForURLAsync, which depends on navigation lifecycle events.
47+
await Assertions.Expect(page).ToHaveURLAsync(
48+
new System.Text.RegularExpressions.Regex(".*migration/session.*"),
49+
new PageAssertionsToHaveURLOptions { Timeout = 60000 });
5050

5151
Assert.Contains("/migration/session", page.Url);
5252
}

0 commit comments

Comments
 (0)