diff --git a/src/Components/WebAssembly/JSInterop/src/WebAssemblyJSObjectReferenceJsonConverter.cs b/src/Components/WebAssembly/JSInterop/src/WebAssemblyJSObjectReferenceJsonConverter.cs index ff294cef2648..38ff95ea0c74 100644 --- a/src/Components/WebAssembly/JSInterop/src/WebAssemblyJSObjectReferenceJsonConverter.cs +++ b/src/Components/WebAssembly/JSInterop/src/WebAssemblyJSObjectReferenceJsonConverter.cs @@ -26,6 +26,12 @@ public override bool CanConvert(Type typeToConvert) public override IJSObjectReference? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { var id = JSObjectReferenceJsonWorker.ReadJSObjectReferenceIdentifier(ref reader); + + if (id == -1) + { + return null; + } + return new WebAssemblyJSObjectReference(_jsRuntime, id); } diff --git a/src/Components/WebAssembly/WebAssembly/test/JSObjectReferenceJsonConverterTest.cs b/src/Components/WebAssembly/WebAssembly/test/JSObjectReferenceJsonConverterTest.cs index 65aeb2acf662..0467e1ac326e 100644 --- a/src/Components/WebAssembly/WebAssembly/test/JSObjectReferenceJsonConverterTest.cs +++ b/src/Components/WebAssembly/WebAssembly/test/JSObjectReferenceJsonConverterTest.cs @@ -44,4 +44,17 @@ public void Read_ReadsJson_IJSInProcessObjectReference() // Assert Assert.Equal(expectedId, deserialized?.Id); } + + [Fact] + public void Read_ReturnsNull_WhenIdIsMinusOne() + { + // Arrange + var json = "{\"__jsObjectId\":-1}"; + + // Act + var deserialized = JsonSerializer.Deserialize(json, JsonSerializerOptions); + + // Assert + Assert.Null(deserialized); + } } diff --git a/src/Components/test/E2ETest/Tests/InteropTest.cs b/src/Components/test/E2ETest/Tests/InteropTest.cs index ef7d9fe18963..07d54f0fa3d8 100644 --- a/src/Components/test/E2ETest/Tests/InteropTest.cs +++ b/src/Components/test/E2ETest/Tests/InteropTest.cs @@ -89,8 +89,6 @@ public void CanInvokeInteropMethods() ["invokeVoidAsyncReturnsWithoutSerializing"] = "Success", ["invokeVoidAsyncReturnsWithoutSerializingInJSObjectReference"] = "Success", ["invokeAsyncThrowsSerializingCircularStructure"] = "Success", - ["invokeAsyncThrowsUndefinedJSObjectReference"] = "Success", - ["invokeAsyncThrowsNullJSObjectReference"] = "Success", ["disposeJSObjectReferenceAsync"] = "Success", // GetValue tests ["getValueFromDataPropertyAsync"] = "10", @@ -108,7 +106,12 @@ public void CanInvokeInteropMethods() ["invokeConstructorWithClassConstructorAsync.function"] = "6", ["invokeConstructorWithNonConstructorAsync"] = "Success", // Function reference tests - ["changeFunctionViaObjectReferenceAsync"] = "42" + ["changeFunctionViaObjectReferenceAsync"] = "42", + // JS Object Nullable reference tests + ["invokeAsyncUndefinedJSObjectReference"] = "Success", + ["invokeAsyncNullJSObjectReference"] = "Success", + ["invokeAsyncNullFromVariableJSObjectReference"] = "Success", + ["invokeAsyncNonExistentJSObjectReference"] = "Success", }; var expectedSyncValues = new Dictionary @@ -148,8 +151,6 @@ public void CanInvokeInteropMethods() ["invokeVoidReturnsWithoutSerializingIJSInProcessRuntime"] = "Success", ["invokeVoidReturnsWithoutSerializingInIJSInProcessObjectReference"] = "Success", ["invokeThrowsSerializingCircularStructure"] = "Success", - ["invokeThrowsUndefinedJSObjectReference"] = "Success", - ["invokeThrowsNullJSObjectReference"] = "Success", ["stringValueUpperSync"] = "MY STRING", ["testDtoNonSerializedValueSync"] = "99999", ["testDtoSync"] = "Same", @@ -174,7 +175,12 @@ public void CanInvokeInteropMethods() ["invokeConstructorWithClassConstructor.function"] = "6", ["invokeConstructorWithNonConstructor"] = "Success", // Function reference tests - ["changeFunctionViaObjectReference"] = "42" + ["changeFunctionViaObjectReference"] = "42", + // JS Object Nullable reference tests + ["invokeUndefinedJSObjectReference"] = "Success", + ["invokeNullJSObjectReference"] = "Success", + ["invokeNullFromVariableJSObjectReference"] = "Success", + ["invokeNonExistentJSObjectReference"] = "Success", }; // Include the sync assertions only when running under WebAssembly diff --git a/src/Components/test/testassets/BasicTestApp/InteropComponent.razor b/src/Components/test/testassets/BasicTestApp/InteropComponent.razor index eb7839539d54..3cafad7b577b 100644 --- a/src/Components/test/testassets/BasicTestApp/InteropComponent.razor +++ b/src/Components/test/testassets/BasicTestApp/InteropComponent.razor @@ -167,33 +167,6 @@ ReturnValues["invokeAsyncThrowsSerializingCircularStructure"] = $"Failure: {ex.Message}"; } - try - { - var undefinedJsObjectReference = await JSRuntime.InvokeAsync("returnUndefined"); - ReturnValues["invokeAsyncThrowsUndefinedJSObjectReference"] = undefinedJsObjectReference is null ? "Failure: null" : "Failure: not null"; - } - catch (JSException) - { - ReturnValues["invokeAsyncThrowsUndefinedJSObjectReference"] = "Success"; - } - catch (Exception ex) - { - ReturnValues["invokeAsyncThrowsUndefinedJSObjectReference"] = $"Failure: {ex.Message}"; - } - - try - { - var nullJsObjectReference = await JSRuntime.InvokeAsync("returnNull"); - ReturnValues["invokeAsyncThrowsNullJSObjectReference"] = nullJsObjectReference is null ? "Failure: null" : "Failure: not null"; - } - catch (JSException) - { - ReturnValues["invokeAsyncThrowsNullJSObjectReference"] = "Success"; - } - catch (Exception ex) - { - ReturnValues["invokeAsyncThrowsNullJSObjectReference"] = $"Failure: {ex.Message}"; - } var jsObjectReference = await JSRuntime.InvokeAsync("returnJSObjectReference"); ReturnValues["jsObjectReference.identity"] = await jsObjectReference.InvokeAsync("identity", "Invoked from JSObjectReference"); @@ -308,6 +281,13 @@ FunctionReferenceTests(); } + await JSObjectReferenceAsyncTests(); + + if (shouldSupportSyncInterop) + { + JSObjectReferenceTests(); + } + Invocations = invocations; DoneWithInterop = true; } @@ -394,34 +374,6 @@ ReturnValues["invokeThrowsSerializingCircularStructure"] = $"Failure: {ex.Message}"; } - try - { - var undefinedJsObjectReference = inProcRuntime.Invoke("returnUndefined"); - ReturnValues["invokeThrowsUndefinedJSObjectReference"] = undefinedJsObjectReference is null ? "Failure: null" : "Failure: not null"; - } - catch (JSException) - { - ReturnValues["invokeThrowsUndefinedJSObjectReference"] = "Success"; - } - catch (Exception ex) - { - ReturnValues["invokeThrowsUndefinedJSObjectReference"] = $"Failure: {ex.Message}"; - } - - try - { - var nullJsObjectReference = inProcRuntime.Invoke("returnNull"); - ReturnValues["invokeThrowsNullJSObjectReference"] = nullJsObjectReference is null ? "Failure: null" : "Failure: not null"; - } - catch (JSException) - { - ReturnValues["invokeThrowsNullJSObjectReference"] = "Success"; - } - catch (Exception ex) - { - ReturnValues["invokeThrowsNullJSObjectReference"] = $"Failure: {ex.Message}"; - } - var jsInProcObjectReference = inProcRuntime.Invoke("returnJSObjectReference"); ReturnValues["jsInProcessObjectReference.identity"] = jsInProcObjectReference.Invoke("identity", "Invoked from JSInProcessObjectReference"); @@ -626,6 +578,124 @@ ReturnValues["changeFunctionViaObjectReference"] = testClassRef.Invoke("getTextLength").ToString(); } + private async Task JSObjectReferenceAsyncTests() + { + try + { + var undefinedJsObjectReference = await JSRuntime.InvokeAsync("jsInteropTests.returnUndefined"); + ReturnValues["invokeAsyncUndefinedJSObjectReference"] = undefinedJsObjectReference is null ? "Success" : $"Failure: not null (type: {undefinedJsObjectReference.GetType().FullName})"; + } + catch (JSException ex) + { + ReturnValues["invokeAsyncUndefinedJSObjectReference"] = $"Failure: {ex.Message}"; + } + catch (Exception ex) + { + ReturnValues["invokeAsyncUndefinedJSObjectReference"] = $"Failure: {ex.Message}"; + } + + try + { + var nullJsObjectReference = await JSRuntime.InvokeAsync("jsInteropTests.returnNull"); + ReturnValues["invokeAsyncNullJSObjectReference"] = nullJsObjectReference is null ? "Success" : $"Failure: not null (type: {nullJsObjectReference.GetType().FullName})"; + } + catch (JSException ex) + { + ReturnValues["invokeAsyncNullJSObjectReference"] = $"Failure: {ex.Message}"; + } + catch (Exception ex) + { + ReturnValues["invokeAsyncNullJSObjectReference"] = $"Failure: {ex.Message}"; + } + + try + { + var nullVariableJsObjectReference = await JSRuntime.GetValueAsync("jsInteropTests.testObject.nullProperty"); + ReturnValues["invokeAsyncNullFromVariableJSObjectReference"] = nullVariableJsObjectReference is null ? "Success" : $"Failure: not null (type: {nullVariableJsObjectReference.GetType().FullName})"; + } + catch (JSException ex) + { + ReturnValues["invokeAsyncNullFromVariableJSObjectReference"] = $"Failure: {ex.Message}"; + } + catch (Exception ex) + { + ReturnValues["invokeAsyncNullFromVariableJSObjectReference"] = $"Failure: {ex.Message}"; + } + + try + { + await JSRuntime.GetValueAsync("nonexistend"); + } + catch (JSException) + { + ReturnValues["invokeAsyncNonExistentJSObjectReference"] = "Success"; + } + catch (Exception ex) + { + ReturnValues["invokeAsyncNonExistentJSObjectReference"] = $"Failure: {ex.Message}"; + } + } + + private void JSObjectReferenceTests() + { + var inProcRuntime = ((IJSInProcessRuntime)JSRuntime); + + try + { + var undefinedJsObjectReference = inProcRuntime.Invoke("returnUndefined"); + ReturnValues["invokeUndefinedJSObjectReference"] = undefinedJsObjectReference is null ? "Success" : $"Failure: not null (type: {undefinedJsObjectReference.GetType().FullName})"; + } + catch (JSException ex) + { + ReturnValues["invokeUndefinedJSObjectReference"] = $"Failure: {ex.Message}"; + } + catch (Exception ex) + { + ReturnValues["invokeUndefinedJSObjectReference"] = $"Failure: {ex.Message}"; + } + + try + { + var nullJsObjectReference = inProcRuntime.Invoke("returnNull"); + ReturnValues["invokeNullJSObjectReference"] = nullJsObjectReference is null ? "Success" : $"Failure: not null (type: {nullJsObjectReference.GetType().FullName})"; + } + catch (JSException ex) + { + ReturnValues["invokeNullJSObjectReference"] = $"Failure: {ex.Message}"; + } + catch (Exception ex) + { + ReturnValues["invokeNullJSObjectReference"] = $"Failure: {ex.Message}"; + } + + try + { + var nullVariableJsObjectReference = inProcRuntime.GetValue("jsInteropTests.testObject.nullProperty"); + ReturnValues["invokeNullFromVariableJSObjectReference"] = nullVariableJsObjectReference is null ? "Success" : $"Failure: not null (type: {nullVariableJsObjectReference.GetType().FullName})"; + } + catch (JSException ex) + { + ReturnValues["invokeNullFromVariableJSObjectReference"] = $"Failure: {ex.Message}"; + } + catch (Exception ex) + { + ReturnValues["invokeNullFromVariableJSObjectReference"] = $"Failure: {ex.Message}"; + } + + try + { + inProcRuntime.GetValue("nonexistend"); + } + catch (JSException) + { + ReturnValues["invokeNonExistentJSObjectReference"] = "Success"; + } + catch (Exception ex) + { + ReturnValues["invokeNonExistentJSObjectReference"] = $"Failure: {ex.Message}"; + } + } + public class PassDotNetObjectByRefArgs { public string StringValue { get; set; } diff --git a/src/Components/test/testassets/BasicTestApp/wwwroot/js/jsinteroptests.js b/src/Components/test/testassets/BasicTestApp/wwwroot/js/jsinteroptests.js index 2a22736a58e2..3faee4235ecb 100644 --- a/src/Components/test/testassets/BasicTestApp/wwwroot/js/jsinteroptests.js +++ b/src/Components/test/testassets/BasicTestApp/wwwroot/js/jsinteroptests.js @@ -234,7 +234,8 @@ const testObject = { }, set setOnlyProperty(value) { this.num = value; - } + }, + nullProperty: null } window.jsInteropTests = { diff --git a/src/JSInterop/Microsoft.JSInterop.JS/src/src/Microsoft.JSInterop.ts b/src/JSInterop/Microsoft.JSInterop.JS/src/src/Microsoft.JSInterop.ts index c7c10cacfef1..8c0a3d9c6999 100644 --- a/src/JSInterop/Microsoft.JSInterop.JS/src/src/Microsoft.JSInterop.ts +++ b/src/JSInterop/Microsoft.JSInterop.JS/src/src/Microsoft.JSInterop.ts @@ -155,6 +155,12 @@ export module DotNet { * @throws Error if the given value is not an Object. */ export function createJSObjectReference(jsObject: any): any { + if (jsObject === null || jsObject === undefined) { + return { + [jsObjectIdKey]: -1 + }; + } + if (jsObject && (typeof jsObject === "object" || jsObject instanceof Function)) { cachedJSObjectsById[nextJsObjectId] = new JSObject(jsObject); @@ -220,7 +226,7 @@ export module DotNet { export function disposeJSObjectReference(jsObjectReference: any): void { const id = jsObjectReference && jsObjectReference[jsObjectIdKey]; - if (typeof id === "number") { + if (typeof id === "number" && id !== -1) { disposeJSObjectReferenceById(id); } } diff --git a/src/JSInterop/Microsoft.JSInterop.JS/src/test/CallDispatcher.test.ts b/src/JSInterop/Microsoft.JSInterop.JS/src/test/CallDispatcher.test.ts index e5b250e41665..fbf2b23261b0 100644 --- a/src/JSInterop/Microsoft.JSInterop.JS/src/test/CallDispatcher.test.ts +++ b/src/JSInterop/Microsoft.JSInterop.JS/src/test/CallDispatcher.test.ts @@ -395,4 +395,55 @@ describe("CallDispatcher", () => { expect(result2).toBe("30"); }); + + test("createJSObjectReference: Handles null values without throwing", () => { + const nullRef = DotNet.createJSObjectReference(null); + expect(nullRef).toEqual({ [jsObjectId]: -1 }); + }); + + test("createJSObjectReference: Handles undefined values without throwing", () => { + const undefinedRef = DotNet.createJSObjectReference(undefined); + expect(undefinedRef).toEqual({ [jsObjectId]: -1 }); + }); + + test("disposeJSObjectReference: Safely handles null reference disposal", () => { + const nullRef = DotNet.createJSObjectReference(null); + expect(() => DotNet.disposeJSObjectReference(nullRef)).not.toThrow(); + }); + + test("createJSObjectReference: Still throws for invalid types", () => { + expect(() => DotNet.createJSObjectReference("string")).toThrow(); + expect(() => DotNet.createJSObjectReference(123)).toThrow(); + expect(() => DotNet.createJSObjectReference(true)).toThrow(); + }); + + test("GetValue: Returns JSObjectReference with sentinel value for null property", () => { + const testObject = { nullProp: null }; + const objectId = getObjectReferenceId(testObject); + + const result = dispatcher.invokeJSFromDotNet( + "nullProp", + "[]", + DotNet.JSCallResultType.JSObjectReference, + objectId, + DotNet.JSCallType.GetValue + ); + + expect(result).toBe('{"__jsObjectId":-1}'); + }); + + test("GetValue: Returns JSObjectReference with sentinel value for undefined property", () => { + const testObject = { undefinedProp: undefined }; + const objectId = getObjectReferenceId(testObject); + + const result = dispatcher.invokeJSFromDotNet( + "undefinedProp", + "[]", + DotNet.JSCallResultType.JSObjectReference, + objectId, + DotNet.JSCallType.GetValue + ); + + expect(result).toBe('{"__jsObjectId":-1}'); + }); }); diff --git a/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/JSObjectReferenceJsonConverter.cs b/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/JSObjectReferenceJsonConverter.cs index 563291822a4a..e4bf9db0c409 100644 --- a/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/JSObjectReferenceJsonConverter.cs +++ b/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/JSObjectReferenceJsonConverter.cs @@ -22,6 +22,12 @@ public override bool CanConvert(Type typeToConvert) public override IJSObjectReference? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { var id = JSObjectReferenceJsonWorker.ReadJSObjectReferenceIdentifier(ref reader); + + if (id == -1) + { + return null; + } + return new JSObjectReference(_jsRuntime, id); } diff --git a/src/JSInterop/Microsoft.JSInterop/test/Infrastructure/JSObjectReferenceJsonConverterTest.cs b/src/JSInterop/Microsoft.JSInterop/test/Infrastructure/JSObjectReferenceJsonConverterTest.cs index 63b5a889782a..383c06819317 100644 --- a/src/JSInterop/Microsoft.JSInterop/test/Infrastructure/JSObjectReferenceJsonConverterTest.cs +++ b/src/JSInterop/Microsoft.JSInterop/test/Infrastructure/JSObjectReferenceJsonConverterTest.cs @@ -87,4 +87,17 @@ public void Write_WritesValidJson() // Assert Assert.Equal($"{{\"__jsObjectId\":{jsObjectRef.Id}}}", json); } + + [Fact] + public void Read_ReturnsNull_WhenJSObjectIdIsMinusOne() + { + // Arrange + var json = "{\"__jsObjectId\":-1}"; + + // Act + var deserialized = JsonSerializer.Deserialize(json, JsonSerializerOptions); + + // Assert + Assert.Null(deserialized); + } }