diff --git a/src/Components/Agents.md b/src/Components/Agents.md
new file mode 100644
index 000000000000..9a67b5f672c0
--- /dev/null
+++ b/src/Components/Agents.md
@@ -0,0 +1,212 @@
+# Working on Issues in the Components Area
+
+This guide provides step-by-step instructions for working on issues in the ASP.NET Core Components area.
+
+## Working on New Features
+
+### Overview
+
+The workflow for implementing new features in the Components area follows these steps:
+
+1. **Create a sample scenario first** - This is the most important first step. Update code in one of the projects in `src/Components/Samples` folder to include the scenarios for the feature you want to build. This allows you to develop and test the feature interactively before writing formal tests.
+
+2. **Build and test interactively** - Build the feature and use Playwright to test it in the browser, ensuring it works end-to-end at a basic level.
+
+### Sample Projects
+
+The `src/Components/Samples` folder contains several sample projects you can use for developing and testing features:
+
+- **BlazorServerApp** - A Blazor Server application for testing server-side scenarios
+- **BlazorUnitedApp** - A Blazor Web App (united/hybrid mode) for testing combined server and WebAssembly scenarios
+- **BlazorUnitedApp.Client** - The client-side portion of the BlazorUnitedApp
+
+**Always start by adding your feature scenario to one of these sample projects first.** This allows you to:
+- Quickly iterate on the implementation
+- Test the feature interactively in a real browser
+- Verify the feature works before writing formal E2E tests
+- Debug issues more easily with full logging capabilities
+
+3. **Debug when needed**:
+ - If something isn't working as expected, increase the logging level in the sample for `Microsoft.AspNetCore.Components` to `Debug` to see detailed logs.
+ - Check browser console logs using Playwright's `browser_console_messages`.
+ - Use Microsoft documentation to learn more about troubleshooting Blazor applications.
+ - You can also increase the log level for JavaScript console output.
+
+4. **Validate the sample works** - You must have a validated, working sample in the Samples folder before proceeding. Use Playwright to confirm the feature works end-to-end in the browser.
+
+5. **Implement E2E tests** - Only after the sample is validated, implement E2E tests for it.
+
+6. **Clean up sample code** - After your E2E tests are passing, remove the sample code you added to the Samples projects. The sample was only for development and interactive testing; the E2E tests now provide the permanent test coverage.
+
+## Build Tips
+
+### Efficient Build Strategy
+
+To avoid unnecessary full repository builds, follow this optimized approach:
+
+#### 1. Initial Setup - Check for First Build
+Before running any commands, check if a full build has already been completed:
+- Look for `artifacts\agent-sentinel.txt` in the repository root
+- If this file exists, skip to step 2
+- If not present, run the initial build and create the sentinel file:
+
+```bash
+.\eng\build.cmd
+echo "We ran eng\build.cmd successfully" > artifacts\agent-sentinel.txt
+```
+
+#### 2. Check for JavaScript Assets
+Before running tests or samples, verify that JavaScript assets are built:
+- Check for `src\Components\Web.JS\dist\Debug\blazor.web.js`
+- If not present, run from the repository root: `npm run build`
+
+#### 3. Iterating on C# Changes
+
+**Most of the time (no dependency changes):**
+```bash
+dotnet build --no-restore -v:q
+```
+
+Or with `eng\build.cmd`:
+```bash
+.\eng\build.cmd -NoRestore -NoBuildDeps -NoBuildRepoTasks -NoBuildNative -NoBuildNodeJS -NoBuildJava -NoBuildInstallers -verbosity:quiet
+```
+
+**When you've added/changed project references or package dependencies:**
+
+First restore:
+```bash
+.\restore.cmd
+```
+
+Then build:
+```bash
+dotnet build --no-restore -v:q
+```
+
+**Note:** The `-v:q` (or `-verbosity:quiet`) flag minimizes build output to only show success/failure and error details. Remove this flag if you need to see detailed build output for debugging.
+
+#### 4. Building Individual Projects (Fixing Build Errors)
+
+When fixing build errors in a specific project, you can build just that project without its dependencies for even faster iteration:
+
+```bash
+dotnet build --no-restore --no-dependencies -v:q
+```
+
+**When to use `--no-dependencies`:**
+- Fixing compilation errors in a single project (syntax errors, type errors, etc.)
+- Making isolated changes that don't affect project references
+- Rapid iteration on a specific library
+
+**When NOT to use `--no-dependencies`:**
+- You've changed public APIs that other projects depend on
+- You need to verify that dependent projects still compile correctly
+- You're unsure if your changes affect other projects (safer to build without this flag)
+
+**Example:**
+```bash
+# Fix a compilation error in Components.Endpoints
+dotnet build src\Components\Endpoints\src\Microsoft.AspNetCore.Components.Endpoints.csproj --no-restore --no-dependencies -v:q
+```
+
+#### Quick Reference
+
+1. **First time only**: `.\eng\build.cmd` → create `artifacts\agent-sentinel.txt`
+2. **Check JS assets**: Verify `src\Components\Web.JS\dist\Debug\blazor.web.js` exists, run `npm run build` if missing
+3. **Most C# changes**: `dotnet build --no-restore -v:q`
+4. **Fixing build errors in one project**: `dotnet build --no-restore --no-dependencies -v:q`
+5. **Added/changed dependencies**: Run `.\restore.cmd` first, then use step 3
+
+### E2E Testing Structure
+
+Tests live in `src/Components/test`. The structure includes:
+
+- **TestAssets folder** - Contains test assets and scenarios
+- **Components.TestServer project** - A web application that launches multiple web servers with different scenarios (different project startups). Avoid adding new startup files unless strictly necessary.
+
+### Running E2E Tests Manually
+
+1. **Build the repository** first with `.\eng\build.cmd` from the repo root
+2. **Start Components.TestServer**:
+ ```bash
+ cd src\Components\test\testassets\Components.TestServer
+ dotnet run --project Components.TestServer.csproj
+ ```
+3. **Navigate to the test server** - The main server runs on `http://127.0.0.1:5019/subdir`
+4. **Select a test scenario** - The main page shows a dropdown with all available test components
+5. **Reproduce the scenario** to verify it works the same way as in the sample
+
+Note: There are also other server instances launched for different test configurations (authentication, CORS, prerendering, etc.). These are listed in the "scenarios" table on the main page.
+
+### Understanding Logging Configuration
+
+#### Server-side (.NET) Logging
+
+The server uses `Microsoft.Extensions.Logging.Testing.TestSink` for capturing logs. Log configuration is in `Program.cs`:
+
+```csharp
+.ConfigureLogging((ctx, lb) =>
+{
+ TestSink sink = new TestSink();
+ lb.AddProvider(new TestLoggerProvider(sink));
+ lb.Services.Add(ServiceDescriptor.Singleton(sink));
+})
+```
+
+#### Client-side (Blazor WebAssembly) Logging
+
+Logs appear in the browser console. Log levels:
+- Logs with `warn:` prefix are Warning level
+- Logs with `info:` prefix are Information level
+- Logs with `fail:` prefix are Error level
+
+The Blazor WebAssembly log level can be configured at startup:
+
+```javascript
+Blazor.start({
+ logLevel: 1 // LogLevel.Debug
+});
+```
+
+LogLevel values: Trace=0, Debug=1, Information=2, Warning=3, Error=4, Critical=5
+
+For Server-side Blazor (SignalR):
+```javascript
+Blazor.start({
+ circuit: {
+ configureSignalR: builder => {
+ builder.configureLogging("debug") // LogLevel.Debug
+ }
+ }
+});
+```
+
+#### Viewing Logs in Playwright
+
+Use `browser_console_messages` to see JavaScript console output including .NET logs routed to the console.
+
+### Creating E2E Tests
+
+E2E tests are located in `src/Components/test/E2ETest`.
+
+1. First, check if there are already E2E tests for the component/feature area you're working on
+2. Try to add an additional test to existing test files when possible
+3. When adding test coverage, prefer extending existing test components and assets over creating a set of new ones if it doesn't complicate the existing ones excessively. This reduces test infrastructure complexity and keeps related scenarios together.
+
+### Running E2E Tests
+
+The E2E tests use Selenium. To build and run tests:
+
+```bash
+# Build the E2E test project (this includes all test assets as dependencies)
+dotnet build src/Components/test/E2ETest/Microsoft.AspNetCore.Components.E2ETests.csproj --no-restore -v:q
+
+# Run a specific test
+dotnet test src/Components/test/E2ETest/Microsoft.AspNetCore.Components.E2ETests.csproj --no-build --filter "FullyQualifiedName~TestName"
+```
+
+**Important**: Never run all E2E tests locally as that is extremely costly. Full test runs should only happen on CI machines.
+
+If a test is failing, it's best to run the server manually and navigate to the test to investigate. The test output won't be very useful for debugging.
+
diff --git a/src/Components/Web.JS/src/Rendering/DomSpecialPropertyUtil.ts b/src/Components/Web.JS/src/Rendering/DomSpecialPropertyUtil.ts
index f5dbc71a6063..0b235e9a1830 100644
--- a/src/Components/Web.JS/src/Rendering/DomSpecialPropertyUtil.ts
+++ b/src/Components/Web.JS/src/Rendering/DomSpecialPropertyUtil.ts
@@ -98,6 +98,11 @@ function tryApplyValueProperty(element: Element, value: string | null): boolean
return true;
}
default:
+ // Support contenteditable elements - set textContent as the value
+ if (element instanceof HTMLElement && element.isContentEditable) {
+ element.textContent = value || '';
+ return true;
+ }
return false;
}
}
diff --git a/src/Components/Web.JS/src/Rendering/Events/EventFieldInfo.ts b/src/Components/Web.JS/src/Rendering/Events/EventFieldInfo.ts
index 12b71f5b6f43..9a4b7906a75c 100644
--- a/src/Components/Web.JS/src/Rendering/Events/EventFieldInfo.ts
+++ b/src/Components/Web.JS/src/Rendering/Events/EventFieldInfo.ts
@@ -33,5 +33,10 @@ function getFormFieldData(elem: Element) {
return { value: elem.value };
}
+ // Support contenteditable elements - use textContent as the value
+ if (elem instanceof HTMLElement && elem.isContentEditable) {
+ return { value: elem.textContent || '' };
+ }
+
return null;
}
diff --git a/src/Components/Web.JS/src/Rendering/Events/EventTypes.ts b/src/Components/Web.JS/src/Rendering/Events/EventTypes.ts
index 89eb799ed3b9..bca454ce9131 100644
--- a/src/Components/Web.JS/src/Rendering/Events/EventTypes.ts
+++ b/src/Components/Web.JS/src/Rendering/Events/EventTypes.ts
@@ -173,6 +173,9 @@ function parseChangeEvent(event: Event): ChangeEventArgs {
.filter(option => option.selected)
.map(option => option.value);
return { value: selectedValues };
+ } else if (isContentEditableElement(element)) {
+ // For contenteditable elements, use textContent as the value
+ return { value: (element as HTMLElement).textContent || '' };
} else {
const targetIsCheckbox = isCheckbox(element);
const newValue = targetIsCheckbox ? !!element['checked'] : element['value'];
@@ -322,6 +325,10 @@ function isCheckbox(element: Element | null): boolean {
return !!element && element.tagName === 'INPUT' && element.getAttribute('type') === 'checkbox';
}
+function isContentEditableElement(element: Element | null): element is HTMLElement {
+ return !!element && element instanceof HTMLElement && element.isContentEditable;
+}
+
const timeBasedInputs = [
'date',
'datetime-local',
diff --git a/src/Components/test/E2ETest/Tests/BindTest.cs b/src/Components/test/E2ETest/Tests/BindTest.cs
index f98a01a8ce35..84269a8ee661 100644
--- a/src/Components/test/E2ETest/Tests/BindTest.cs
+++ b/src/Components/test/E2ETest/Tests/BindTest.cs
@@ -2093,4 +2093,61 @@ private void ApplyInputValue(string cssSelector, string value)
javascript.ExecuteScript(
$"document.querySelector('{cssSelector}').dispatchEvent(new KeyboardEvent('change'));");
}
+
+ [Fact]
+ public void CanBindContentEditableDiv_WithOninput()
+ {
+ var target = Browser.Exists(By.Id("contenteditable-oninput"));
+ var boundValue = Browser.Exists(By.Id("contenteditable-oninput-value"));
+ Assert.Equal("Initial content", target.Text);
+ Assert.Equal("Initial content", boundValue.Text);
+
+ // Use JavaScript to set content and dispatch input event
+ // (SendKeys on contenteditable causes issues because Blazor re-rendering moves the caret)
+ ((IJavaScriptExecutor)Browser).ExecuteScript(@"
+ var el = arguments[0];
+ el.textContent = 'New content';
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+ ", target);
+
+ Browser.Equal("New content", () => boundValue.Text);
+ }
+
+ [Fact]
+ public void CanBindContentEditableDiv_WithOnchange()
+ {
+ var target = Browser.Exists(By.Id("contenteditable-onchange"));
+ var boundValue = Browser.Exists(By.Id("contenteditable-onchange-value"));
+ Assert.Equal("Change on blur", target.Text);
+ Assert.Equal("Change on blur", boundValue.Text);
+
+ // Focus the element first, then set content, then blur
+ // The contentblur event fires on blur, so we need proper focus/blur sequence
+ ((IJavaScriptExecutor)Browser).ExecuteScript(@"
+ var el = arguments[0];
+ el.focus();
+ el.textContent = 'Updated value';
+ el.blur();
+ ", target);
+
+ Browser.Equal("Updated value", () => boundValue.Text);
+ }
+
+ [Fact]
+ public void CanBindContentEditableDiv_InitiallyBlank()
+ {
+ var target = Browser.Exists(By.Id("contenteditable-initially-blank"));
+ var boundValue = Browser.Exists(By.Id("contenteditable-initially-blank-value"));
+ Assert.Equal(string.Empty, target.Text);
+ Assert.Equal(string.Empty, boundValue.Text);
+
+ // Use JavaScript to set content and dispatch input event
+ ((IJavaScriptExecutor)Browser).ExecuteScript(@"
+ var el = arguments[0];
+ el.textContent = 'Added content';
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+ ", target);
+
+ Browser.Equal("Added content", () => boundValue.Text);
+ }
}
diff --git a/src/Components/test/testassets/BasicTestApp/BindCasesComponent.razor b/src/Components/test/testassets/BasicTestApp/BindCasesComponent.razor
index 56bc049ebcb4..ef86c0174796 100644
--- a/src/Components/test/testassets/BasicTestApp/BindCasesComponent.razor
+++ b/src/Components/test/testassets/BasicTestApp/BindCasesComponent.razor
@@ -472,6 +472,32 @@
@timeStepTextboxTimeOnlyValue.ToLongTimeString()
+ContentEditable
+
+ ContentEditable div with oninput (standard event):
+
@contentEditableValue
+ @contentEditableValue
+
+
+ ContentEditable div with custom contentblur event (blur doesn't fire change on contenteditable):
+
@contentEditableChangeValue
+ @contentEditableChangeValue
+
+
+ ContentEditable initially blank:
+
@contentEditableBlankValue
+ @contentEditableBlankValue
+
+
@code {
string textboxInitiallyBlankValue = null;
string textboxInitiallyPopulatedValue = "Hello";
@@ -575,4 +601,24 @@
selectValue = SelectableValue.Sixth;
variableValue = SelectableValue.Sixth;
}
+
+ // ContentEditable binding
+ string contentEditableValue = "Initial content";
+ string contentEditableChangeValue = "Change on blur";
+ string contentEditableBlankValue = "";
+
+ void OnContentEditableInput(ChangeEventArgs e)
+ {
+ contentEditableValue = e.Value?.ToString() ?? "";
+ }
+
+ void OnContentEditableChange(ContentEditableEventArgs e)
+ {
+ contentEditableChangeValue = e.TextContent ?? "";
+ }
+
+ void OnContentEditableBlankInput(ChangeEventArgs e)
+ {
+ contentEditableBlankValue = e.Value?.ToString() ?? "";
+ }
}
diff --git a/src/Components/test/testassets/BasicTestApp/EventCustomArgsTypes.cs b/src/Components/test/testassets/BasicTestApp/EventCustomArgsTypes.cs
index 469eb100ca8c..71dcd2b0ced5 100644
--- a/src/Components/test/testassets/BasicTestApp/EventCustomArgsTypes.cs
+++ b/src/Components/test/testassets/BasicTestApp/EventCustomArgsTypes.cs
@@ -11,6 +11,7 @@ namespace BasicTestApp.CustomEventTypesNamespace;
[EventHandler("onkeydown.yetanother", typeof(YetAnotherCustomKeyboardEventArgs), true, true)]
[EventHandler("oncustommouseover", typeof(EventArgs), true, true)]
[EventHandler("onsendjsobject", typeof(EventWithCustomSerializedDataEventArgs), true, true)]
+[EventHandler("oncontentblur", typeof(ContentEditableEventArgs), true, true)]
public static class EventHandlers
{
}
@@ -48,3 +49,12 @@ public DotNetType(string propertyValue)
Property = propertyValue;
}
}
+
+///
+/// Event arguments for contenteditable elements that capture both textContent and innerHTML.
+///
+class ContentEditableEventArgs : EventArgs
+{
+ public string TextContent { get; set; }
+ public string InnerHTML { get; set; }
+}
diff --git a/src/Components/test/testassets/BasicTestApp/_Imports.razor b/src/Components/test/testassets/BasicTestApp/_Imports.razor
index ca71cae52dfa..e36b82a12918 100644
--- a/src/Components/test/testassets/BasicTestApp/_Imports.razor
+++ b/src/Components/test/testassets/BasicTestApp/_Imports.razor
@@ -1,3 +1,4 @@
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.Web.Media
+@using BasicTestApp.CustomEventTypesNamespace
diff --git a/src/Components/test/testassets/BasicTestApp/wwwroot/BasicTestApp.lib.module.js b/src/Components/test/testassets/BasicTestApp/wwwroot/BasicTestApp.lib.module.js
index a0e30c0a8e79..ce72f2c82964 100644
--- a/src/Components/test/testassets/BasicTestApp/wwwroot/BasicTestApp.lib.module.js
+++ b/src/Components/test/testassets/BasicTestApp/wwwroot/BasicTestApp.lib.module.js
@@ -4,6 +4,26 @@ let resourceRequests = [];
// we are using the resource list in BootResourceCachingTest and when it's too full it stops reporting correctly
window.performance.setResourceTimingBufferSize(1000);
+// Helper function to register contentblur custom event for contenteditable elements.
+// Note: The standard 'input' event works for oninput on contenteditable elements.
+// However, 'change' events don't fire on blur for contenteditable, so we need
+// a custom event mapped to 'blur' to handle that scenario.
+function registerContentBlurEvent(blazorInstance) {
+ blazorInstance.registerCustomEventType('contentblur', {
+ browserEventName: 'blur',
+ createEventArgs: event => {
+ const element = event.target;
+ if (element instanceof HTMLElement && element.isContentEditable) {
+ return {
+ textContent: element.textContent || '',
+ innerHTML: element.innerHTML || ''
+ };
+ }
+ return { textContent: '', innerHTML: '' };
+ }
+ });
+}
+
export async function beforeStart(options) {
const url = new URL(document.URL);
runInitializer = url.hash.indexOf('initializer') !== -1;
@@ -24,6 +44,12 @@ export async function beforeStart(options) {
}
export async function afterStarted() {
+ // Register custom contentblur event for contenteditable elements using global Blazor object.
+ // Standard 'change' events don't fire on blur for contenteditable, so we need this custom event.
+ if (typeof Blazor !== 'undefined' && Blazor.registerCustomEventType) {
+ registerContentBlurEvent(Blazor);
+ }
+
if (runInitializer) {
const end = document.createElement('p');
end.setAttribute('id', 'initializer-end');