Skip to content

Fix FieldIdentifier.GetHashCode() NullReferenceException for default struct#65573

Open
kubaflo wants to merge 1 commit intodotnet:mainfrom
kubaflo:fix/null-fieldname-gethashcode-45096
Open

Fix FieldIdentifier.GetHashCode() NullReferenceException for default struct#65573
kubaflo wants to merge 1 commit intodotnet:mainfrom
kubaflo:fix/null-fieldname-gethashcode-45096

Conversation

@kubaflo
Copy link

@kubaflo kubaflo commented Mar 1, 2026

Summary

Fixes #45096default(FieldIdentifier).GetHashCode() throws NullReferenceException

Root Cause

FieldIdentifier is a readonly struct. Using default(FieldIdentifier) bypasses the constructor, leaving FieldName as null. The GetHashCode() method calls StringComparer.Ordinal.GetHashCode(FieldName) which throws NullReferenceException when FieldName is null.

Fix

Add a null check in GetHashCode():

var fieldHash = FieldName is null ? 0 : StringComparer.Ordinal.GetHashCode(FieldName);

Changes

  • src/Components/Forms/src/FieldIdentifier.cs — null-safe GetHashCode() (1 line change)
  • src/Components/Forms/test/FieldIdentifierTest.cs — 3 new tests for default(FieldIdentifier) scenarios

Multi-Model Exploration

# Model Approach Result
0 manual Explicit is null check ✅ Selected
1 claude-sonnet-4.6 FieldName?.GetHashCode(StringComparison.Ordinal) ?? 0
2 claude-opus-4.6 FieldName ?? string.Empty in GetHashCode+Equals
3 gpt-5.2 EqualityComparer<string>.Default.GetHashCode()
4 gpt-5.3-codex Backing field with property getter
5 gemini-3-pro HashCode.Combine with null-coalescing

Selected Attempt 0 — minimal 1-line change, preserves existing StringComparer.Ordinal hash algorithm, clear intent, zero risk of behavioral changes for valid FieldIdentifier instances.

Test Results

All 81 tests in Microsoft.AspNetCore.Components.Forms.Tests pass with zero regressions.

Copilot AI review requested due to automatic review settings March 1, 2026 00:44
@kubaflo kubaflo requested a review from a team as a code owner March 1, 2026 00:44
@github-actions github-actions bot added the area-blazor Includes: Blazor, Razor Components label Mar 1, 2026
@dotnet-policy-service dotnet-policy-service bot added the community-contribution Indicates that the PR has been added by a community member label Mar 1, 2026
@dotnet-policy-service
Copy link
Contributor

Thanks for your PR, @@kubaflo. Someone from the team will get assigned to your PR shortly and we'll get it reviewed.

@kubaflo
Copy link
Author

kubaflo commented Mar 1, 2026

🤖 AI Fix Summary

📋 Pre-Flight

Issue: #45096default(FieldIdentifier).GetHashCode() throws NullReferenceException
Area: area-blazor (src/Components/Forms/)
Root Cause: FieldIdentifier is a readonly struct. default(FieldIdentifier) bypasses the constructor, leaving FieldName as null. StringComparer.Ordinal.GetHashCode(null) throws.

🧪 Tests (3 added)
  • DefaultFieldIdentifier_GetHashCode_DoesNotThrow — verifies no exception on default(FieldIdentifier).GetHashCode()
  • DefaultFieldIdentifier_Equals_DoesNotThrow — verifies default(FieldIdentifier).Equals(default) works
  • DefaultFieldIdentifier_DoesNotEqualInitializedFieldIdentifier — verifies default ≠ initialized
🔬 Multi-Model Exploration (6 attempts, all passing)
# Model Approach Result
0 manual Explicit is null check Selected
1 claude-sonnet-4.6 FieldName?.GetHashCode(StringComparison.Ordinal) ?? 0
2 claude-opus-4.6 FieldName ?? string.Empty normalization
3 gpt-5.2 EqualityComparer<string>.Default
4 gpt-5.3-codex Backing field property getter
5 gemini-3-pro HashCode.Combine

Cross-pollination: 2 models reviewed — no new ideas. Exhausted: Yes.

✅ Fix Applied

1 line changed in FieldIdentifier.GetHashCode():

var fieldHash = FieldName is null ? 0 : StringComparer.Ordinal.GetHashCode(FieldName);

81/81 tests pass — zero regressions.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a crash in Microsoft.AspNetCore.Components.Forms.FieldIdentifier when default(FieldIdentifier) is used by making hashing behavior null-safe, and adds regression coverage in the Forms test suite.

Changes:

  • Make FieldIdentifier.GetHashCode() null-safe for FieldName when the struct is default-initialized.
  • Add tests covering default(FieldIdentifier) hashing and equality scenarios.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
src/Components/Forms/src/FieldIdentifier.cs Adjusts GetHashCode() to avoid exceptions when FieldName is null on default structs.
src/Components/Forms/test/FieldIdentifierTest.cs Adds tests to validate default(FieldIdentifier) behavior (hashing and equality).

@@ -75,7 +75,7 @@ public override int GetHashCode()
{
// We want to compare Model instances by reference. RuntimeHelpers.GetHashCode returns identical hashes for equal object references (ignoring any `Equals`/`GetHashCode` overrides) which is what we want.
var modelHash = RuntimeHelpers.GetHashCode(Model);
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetHashCode() still calls RuntimeHelpers.GetHashCode(Model) without guarding against Model being null when the struct is default(FieldIdentifier). RuntimeHelpers.GetHashCode(null) throws, so default(FieldIdentifier).GetHashCode() will still throw despite the FieldName null-check. Consider also handling a null Model (e.g., using 0 or another sentinel hash) so the new default-struct tests pass and the method aligns with the PR’s intent.

Suggested change
var modelHash = RuntimeHelpers.GetHashCode(Model);
var modelHash = Model is null ? 0 : RuntimeHelpers.GetHashCode(Model);

Copilot uses AI. Check for mistakes.
…struct

default(FieldIdentifier) bypasses the constructor, leaving FieldName as null.
StringComparer.Ordinal.GetHashCode(null) then throws NullReferenceException.

Add null check in GetHashCode to handle default struct gracefully.
Add tests for default(FieldIdentifier) GetHashCode, Equals, and inequality.

Fixes dotnet#45096

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@kubaflo kubaflo force-pushed the fix/null-fieldname-gethashcode-45096 branch from 6f1b501 to ba6cec5 Compare March 1, 2026 11:34
@kubaflo
Copy link
Author

kubaflo commented Mar 1, 2026

🤖 AI Summary

📊 Expand Full Review
🔍 Pre-Flight — Context & Validation

Issue: #45096 - Blazor Value cannot be null. (Parameter 'obj')
Area: area-blazor (src/Components/Forms/)
PR: #48352 (closed, not merged — only improved constructor error message, didn't fix GetHashCode)

Key Findings

  • FieldIdentifier is a readonly struct — has a default value where FieldName and Model are both null
  • When a custom component creates a FieldIdentifier without using @bind (no ValueExpression), FieldName stays null
  • GetHashCode() calls StringComparer.Ordinal.GetHashCode(FieldName) which throws ArgumentNullException: Value cannot be null. (Parameter 'obj') when FieldName is null
  • The constructor validates fieldName != null, but struct default bypasses the constructor
  • Root cause: GetHashCode() and Equals() are not null-safe for FieldName

Test Command

dotnet test src/Components/Forms/test/Microsoft.AspNetCore.Components.Forms.Tests.csproj --filter "FullyQualifiedName~FieldIdentifier"

Fix Candidates

# Source Approach Files Changed Notes
1 Issue comments Make GetHashCode() null-safe using FieldName?.GetHashCode() ?? 0 FieldIdentifier.cs Simple, handles struct default
2 Issue comments Throw ArgumentException with descriptive message in GetHashCode when FieldName is null FieldIdentifier.cs Better error message
3 Combined Null-safe GetHashCode/Equals + throw descriptive error in NotifyFieldChanged FieldIdentifier.cs, EditContext.cs Most robust

🧪 Test — Bug Reproduction

Test Result: ✅ TESTS CREATED

Test Command: dotnet test src/Components/Forms/test/Microsoft.AspNetCore.Components.Forms.Tests.csproj --filter "FullyQualifiedName~DefaultFieldIdentifier"
Tests Created: src/Components/Forms/test/FieldIdentifierTest.cs

Tests Written

  • DefaultFieldIdentifier_GetHashCode_DoesNotThrow — verifies default(FieldIdentifier).GetHashCode() doesn't throw (currently throws ArgumentNullException)
  • DefaultFieldIdentifier_Equals_DoesNotThrow — verifies two default FieldIdentifiers can be compared
  • DefaultFieldIdentifier_DoesNotEqualInitializedFieldIdentifier — verifies default != initialized

Conclusion

The first test directly reproduces the bug: StringComparer.Ordinal.GetHashCode(null) throws in GetHashCode().


🚦 Gate — Test Verification & Regression

Gate Result: ✅ PASSED

Test Command: dotnet test src/Components/Forms/test/Microsoft.AspNetCore.Components.Forms.Tests.csproj

New Tests vs Buggy Code

  • DefaultFieldIdentifier_GetHashCode_DoesNotThrow: FAIL as expected (NullReferenceException)
  • DefaultFieldIdentifier_Equals_DoesNotThrow: PASS (string.Equals handles null)
  • DefaultFieldIdentifier_DoesNotEqualInitializedFieldIdentifier: PASS

Regression Check

  • Total tests run: 81
  • Pre-existing failures: 0
  • New failures introduced: 0

Conclusion

Tests properly catch the bug. Fix applied: null-check FieldName in GetHashCode. All 81 tests pass.


🔧 Fix — Analysis & Comparison (✅ 6 passed)

Fix Exploration Summary

Total Attempts: 6
Passing Candidates: 6
Selected Fix: Attempt 0 — explicit null check

Attempt Results

# Model Approach Result Key Insight
0 manual FieldName is null ? 0 : StringComparer.Ordinal.GetHashCode(FieldName) Minimal, preserves hash algo
1 claude-sonnet-4.6 FieldName?.GetHashCode(StringComparison.Ordinal) ?? 0 Idiomatic C#
2 claude-opus-4.6 FieldName ?? string.Empty in GetHashCode and Equals More changes than needed
3 gpt-5.2 EqualityComparer<string>.Default.GetHashCode(FieldName) Changes comparer type
4 gpt-5.3-codex Backing field _fieldName with property getter Invasive API change
5 gemini-3-pro HashCode.Combine with null-coalescing Changes hash algorithm

Cross-Pollination

Model Round New Ideas? Details
claude-sonnet-4.6 2 No NO NEW IDEAS
gpt-5.2 2 Yes (not better) Cache hash in readonly field — bigger change, not justified

Exhausted: Yes

Comparison

Criterion Attempt 0 Attempt 1 Attempt 2 Attempt 3 Attempt 4 Attempt 5
Correctness ⚠️ ⚠️
Simplicity 1 line 1 line 2+ lines 1 line 5+ lines 3+ lines
Backward compat ⚠️ ⚠️ ⚠️
Preserves hash ⚠️

Recommendation

Attempt 0 is best: minimal 1-line change, preserves existing StringComparer.Ordinal hash algorithm, clear intent, zero risk of behavioral changes for valid FieldIdentifiers.

Attempt 0: PASS

Attempt 0: Initial Fix (manual)

Approach: Null-conditional check on FieldName in GetHashCode
Change: FieldName is null ? 0 : StringComparer.Ordinal.GetHashCode(FieldName)
Files: src/Components/Forms/src/FieldIdentifier.cs (1 line)
Result: ✅ PASS — 81/81 tests pass

📄 Diff
diff --git a/src/Components/Forms/src/FieldIdentifier.cs b/src/Components/Forms/src/FieldIdentifier.cs
index 5764b018e4..05d6430593 100644
--- a/src/Components/Forms/src/FieldIdentifier.cs
+++ b/src/Components/Forms/src/FieldIdentifier.cs
@@ -75,7 +75,7 @@ public readonly struct FieldIdentifier : IEquatable<FieldIdentifier>
     {
         // We want to compare Model instances by reference. RuntimeHelpers.GetHashCode returns identical hashes for equal object references (ignoring any `Equals`/`GetHashCode` overrides) which is what we want.
         var modelHash = RuntimeHelpers.GetHashCode(Model);
-        var fieldHash = StringComparer.Ordinal.GetHashCode(FieldName);
+        var fieldHash = FieldName is null ? 0 : StringComparer.Ordinal.GetHashCode(FieldName);
         return (
             modelHash,
             fieldHash
Attempt 1: PASS

Approach: Use ?.GetHashCode(StringComparison.Ordinal) ?? 0

Fix

Replace StringComparer.Ordinal.GetHashCode(FieldName) with FieldName?.GetHashCode(StringComparison.Ordinal) ?? 0 in the GetHashCode() method of FieldIdentifier.

Rationale

  • default(FieldIdentifier) bypasses the constructor, leaving FieldName as null.
  • StringComparer.Ordinal.GetHashCode(null) throws ArgumentNullException.
  • Using null-conditional ?.GetHashCode(StringComparison.Ordinal) ?? 0 avoids the exception by returning 0 when FieldName is null.
  • This approach uses the instance method string.GetHashCode(StringComparison) directly, which is semantically equivalent but avoids allocating a StringComparer object and uses the null-conditional operator idiomatically.
  • Different from Attempt 0 which used an explicit is null guard; this uses the ?. null-conditional operator with null-coalescing ?? 0.
📄 Diff
diff --git a/src/Components/Forms/src/FieldIdentifier.cs b/src/Components/Forms/src/FieldIdentifier.cs
index 5764b018e4..2b3208f844 100644
--- a/src/Components/Forms/src/FieldIdentifier.cs
+++ b/src/Components/Forms/src/FieldIdentifier.cs
@@ -75,7 +75,7 @@ public readonly struct FieldIdentifier : IEquatable<FieldIdentifier>
     {
         // We want to compare Model instances by reference. RuntimeHelpers.GetHashCode returns identical hashes for equal object references (ignoring any `Equals`/`GetHashCode` overrides) which is what we want.
         var modelHash = RuntimeHelpers.GetHashCode(Model);
-        var fieldHash = StringComparer.Ordinal.GetHashCode(FieldName);
+        var fieldHash = FieldName?.GetHashCode(StringComparison.Ordinal) ?? 0;
         return (
             modelHash,
             fieldHash
Attempt 2: PASS

Attempt 2: Normalize null FieldName to string.Empty

Approach

Use FieldName ?? string.Empty to coalesce null to empty string in both GetHashCode() and Equals().

This differs from prior attempts:

  • Attempt 0 used an explicit FieldName is null ? 0 : ternary in GetHashCode only.
  • Attempt 1 used FieldName?.GetHashCode(StringComparison.Ordinal) ?? 0 (null-conditional on instance method) in GetHashCode only.

This approach normalizes null → empty string at point-of-use in both GetHashCode AND Equals, ensuring consistent behavior: default(FieldIdentifier) is treated equivalently to a FieldIdentifier with FieldName = "". The StringComparer.Ordinal.GetHashCode call is preserved (no API change), and string.Equals receives non-null arguments, making behavior fully explicit rather than relying on string.Equals silently handling null.

Changes

  • FieldIdentifier.GetHashCode(): StringComparer.Ordinal.GetHashCode(FieldName ?? string.Empty)
  • FieldIdentifier.Equals(FieldIdentifier): string.Equals(otherIdentifier.FieldName ?? string.Empty, FieldName ?? string.Empty, StringComparison.Ordinal)
📄 Diff
diff --git a/src/Components/Forms/src/FieldIdentifier.cs b/src/Components/Forms/src/FieldIdentifier.cs
index 5764b018e4..f68f5df553 100644
--- a/src/Components/Forms/src/FieldIdentifier.cs
+++ b/src/Components/Forms/src/FieldIdentifier.cs
@@ -75,7 +75,7 @@ public readonly struct FieldIdentifier : IEquatable<FieldIdentifier>
     {
         // We want to compare Model instances by reference. RuntimeHelpers.GetHashCode returns identical hashes for equal object references (ignoring any `Equals`/`GetHashCode` overrides) which is what we want.
         var modelHash = RuntimeHelpers.GetHashCode(Model);
-        var fieldHash = StringComparer.Ordinal.GetHashCode(FieldName);
+        var fieldHash = StringComparer.Ordinal.GetHashCode(FieldName ?? string.Empty);
         return (
             modelHash,
             fieldHash
@@ -92,7 +92,7 @@ public readonly struct FieldIdentifier : IEquatable<FieldIdentifier>
     public bool Equals(FieldIdentifier otherIdentifier)
     {
         return ReferenceEquals(otherIdentifier.Model, Model) &&
-            string.Equals(otherIdentifier.FieldName, FieldName, StringComparison.Ordinal);
+            string.Equals(otherIdentifier.FieldName ?? string.Empty, FieldName ?? string.Empty, StringComparison.Ordinal);
     }
 
     private static void ParseAccessor<T>(Expression<Func<T>> accessor, out object model, out string fieldName)
Attempt 3: PASS

Attempt 3 - Alternative fix

Idea

Avoid NullReferenceException for default(FieldIdentifier) by computing the field-name hash using a null-safe comparer rather than calling StringComparer.Ordinal.GetHashCode(FieldName).

Change

In FieldIdentifier.GetHashCode(), replace:

  • StringComparer.Ordinal.GetHashCode(FieldName)

with:

  • EqualityComparer<string>.Default.GetHashCode(FieldName)

EqualityComparer<string>.Default.GetHashCode(null) returns 0, so default(FieldIdentifier) no longer throws, while keeping consistent behavior with the existing ordinal string equality used in Equals.

📄 Diff
diff --git a/src/Components/Forms/src/FieldIdentifier.cs b/src/Components/Forms/src/FieldIdentifier.cs
index 5764b018e4..71044455c7 100644
--- a/src/Components/Forms/src/FieldIdentifier.cs
+++ b/src/Components/Forms/src/FieldIdentifier.cs
@@ -75,7 +75,7 @@ public readonly struct FieldIdentifier : IEquatable<FieldIdentifier>
     {
         // We want to compare Model instances by reference. RuntimeHelpers.GetHashCode returns identical hashes for equal object references (ignoring any `Equals`/`GetHashCode` overrides) which is what we want.
         var modelHash = RuntimeHelpers.GetHashCode(Model);
-        var fieldHash = StringComparer.Ordinal.GetHashCode(FieldName);
+        var fieldHash = EqualityComparer<string>.Default.GetHashCode(FieldName);
         return (
             modelHash,
             fieldHash
Attempt 4: PASS

Alternative fix: replace the auto-property backing for FieldName with an explicit nullable backing field (_fieldName) and expose FieldName through a getter that returns _fieldName ?? string.Empty.

Why this is different:

  • It does not add null checks directly in GetHashCode.
  • It does not use null-conditional/coalescing logic in GetHashCode or Equals.
  • It does not normalize inside equality/hash methods.
  • It avoids relying on EqualityComparer<string>.Default.

Behavior:

  • Constructor path is unchanged for valid instances (fieldName is still required and stored).
  • default(FieldIdentifier) now surfaces FieldName as string.Empty, so StringComparer.Ordinal.GetHashCode(FieldName) no longer throws.
📄 Diff
diff --git a/src/Components/Forms/src/FieldIdentifier.cs b/src/Components/Forms/src/FieldIdentifier.cs
index 5764b018e4..6b31b7b401 100644
--- a/src/Components/Forms/src/FieldIdentifier.cs
+++ b/src/Components/Forms/src/FieldIdentifier.cs
@@ -57,7 +57,7 @@ public readonly struct FieldIdentifier : IEquatable<FieldIdentifier>
 
         // Note that we do allow an empty string. This is used by some validation systems
         // as a place to store object-level (not per-property) messages.
-        FieldName = fieldName ?? throw new ArgumentNullException(nameof(fieldName));
+        _fieldName = fieldName ?? throw new ArgumentNullException(nameof(fieldName));
     }
 
     /// <summary>
@@ -65,10 +65,12 @@ public readonly struct FieldIdentifier : IEquatable<FieldIdentifier>
     /// </summary>
     public object Model { get; }
 
+    private readonly string? _fieldName;
+
     /// <summary>
     /// Gets the name of the editable field.
     /// </summary>
-    public string FieldName { get; }
+    public string FieldName => _fieldName ?? string.Empty;
 
     /// <inheritdoc />
     public override int GetHashCode()
Attempt 5: PASS

Approach

I modified GetHashCode to use HashCode.Combine along with StringComparer.Ordinal.GetHashCode(FieldName ?? string.Empty).

This approach avoids the NullReferenceException when FieldName is null by falling back to string.Empty for hash calculation. While this causes a hash collision between a FieldIdentifier with FieldName = null and one with FieldName = "", this is acceptable as Equals correctly distinguishes them, and GetHashCode is only required to return the same value for equal objects (the converse is not required).

This uses the modern HashCode.Combine API which is efficient and standard.

    public override int GetHashCode()
    {
        // We want to compare Model instances by reference. RuntimeHelpers.GetHashCode returns identical hashes for equal object references (ignoring any `Equals`/`GetHashCode` overrides) which is what we want.
        return HashCode.Combine(RuntimeHelpers.GetHashCode(Model), StringComparer.Ordinal.GetHashCode(FieldName ?? string.Empty));
    }
📄 Diff
diff --git a/src/Components/Forms/src/FieldIdentifier.cs b/src/Components/Forms/src/FieldIdentifier.cs
index 5764b018e4..683b428389 100644
--- a/src/Components/Forms/src/FieldIdentifier.cs
+++ b/src/Components/Forms/src/FieldIdentifier.cs
@@ -74,13 +74,7 @@ public readonly struct FieldIdentifier : IEquatable<FieldIdentifier>
     public override int GetHashCode()
     {
         // We want to compare Model instances by reference. RuntimeHelpers.GetHashCode returns identical hashes for equal object references (ignoring any `Equals`/`GetHashCode` overrides) which is what we want.
-        var modelHash = RuntimeHelpers.GetHashCode(Model);
-        var fieldHash = StringComparer.Ordinal.GetHashCode(FieldName);
-        return (
-            modelHash,
-            fieldHash
-        )
-        .GetHashCode();
+        return HashCode.Combine(RuntimeHelpers.GetHashCode(Model), StringComparer.Ordinal.GetHashCode(FieldName ?? string.Empty));
     }
 
     /// <inheritdoc />

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-blazor Includes: Blazor, Razor Components community-contribution Indicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Blazor Value cannot be null. (Parameter 'obj')

2 participants