Skip to content

Commit 36e93fa

Browse files
authored
Enable IJSObjectReference to handle null/undefined values (#62657)
* Enable `IJSObjectReference` to handle null/undefined values * Added E2E tests
1 parent 08cadfe commit 36e93fa

File tree

9 files changed

+235
-63
lines changed

9 files changed

+235
-63
lines changed

src/Components/WebAssembly/JSInterop/src/WebAssemblyJSObjectReferenceJsonConverter.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ public override bool CanConvert(Type typeToConvert)
2626
public override IJSObjectReference? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
2727
{
2828
var id = JSObjectReferenceJsonWorker.ReadJSObjectReferenceIdentifier(ref reader);
29+
30+
if (id == -1)
31+
{
32+
return null;
33+
}
34+
2935
return new WebAssemblyJSObjectReference(_jsRuntime, id);
3036
}
3137

src/Components/WebAssembly/WebAssembly/test/JSObjectReferenceJsonConverterTest.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,17 @@ public void Read_ReadsJson_IJSInProcessObjectReference()
4444
// Assert
4545
Assert.Equal(expectedId, deserialized?.Id);
4646
}
47+
48+
[Fact]
49+
public void Read_ReturnsNull_WhenIdIsMinusOne()
50+
{
51+
// Arrange
52+
var json = "{\"__jsObjectId\":-1}";
53+
54+
// Act
55+
var deserialized = JsonSerializer.Deserialize<IJSObjectReference>(json, JsonSerializerOptions);
56+
57+
// Assert
58+
Assert.Null(deserialized);
59+
}
4760
}

src/Components/test/E2ETest/Tests/InteropTest.cs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,6 @@ public void CanInvokeInteropMethods()
8989
["invokeVoidAsyncReturnsWithoutSerializing"] = "Success",
9090
["invokeVoidAsyncReturnsWithoutSerializingInJSObjectReference"] = "Success",
9191
["invokeAsyncThrowsSerializingCircularStructure"] = "Success",
92-
["invokeAsyncThrowsUndefinedJSObjectReference"] = "Success",
93-
["invokeAsyncThrowsNullJSObjectReference"] = "Success",
9492
["disposeJSObjectReferenceAsync"] = "Success",
9593
// GetValue tests
9694
["getValueFromDataPropertyAsync"] = "10",
@@ -108,7 +106,12 @@ public void CanInvokeInteropMethods()
108106
["invokeConstructorWithClassConstructorAsync.function"] = "6",
109107
["invokeConstructorWithNonConstructorAsync"] = "Success",
110108
// Function reference tests
111-
["changeFunctionViaObjectReferenceAsync"] = "42"
109+
["changeFunctionViaObjectReferenceAsync"] = "42",
110+
// JS Object Nullable reference tests
111+
["invokeAsyncUndefinedJSObjectReference"] = "Success",
112+
["invokeAsyncNullJSObjectReference"] = "Success",
113+
["invokeAsyncNullFromVariableJSObjectReference"] = "Success",
114+
["invokeAsyncNonExistentJSObjectReference"] = "Success",
112115
};
113116

114117
var expectedSyncValues = new Dictionary<string, string>
@@ -148,8 +151,6 @@ public void CanInvokeInteropMethods()
148151
["invokeVoidReturnsWithoutSerializingIJSInProcessRuntime"] = "Success",
149152
["invokeVoidReturnsWithoutSerializingInIJSInProcessObjectReference"] = "Success",
150153
["invokeThrowsSerializingCircularStructure"] = "Success",
151-
["invokeThrowsUndefinedJSObjectReference"] = "Success",
152-
["invokeThrowsNullJSObjectReference"] = "Success",
153154
["stringValueUpperSync"] = "MY STRING",
154155
["testDtoNonSerializedValueSync"] = "99999",
155156
["testDtoSync"] = "Same",
@@ -174,7 +175,12 @@ public void CanInvokeInteropMethods()
174175
["invokeConstructorWithClassConstructor.function"] = "6",
175176
["invokeConstructorWithNonConstructor"] = "Success",
176177
// Function reference tests
177-
["changeFunctionViaObjectReference"] = "42"
178+
["changeFunctionViaObjectReference"] = "42",
179+
// JS Object Nullable reference tests
180+
["invokeUndefinedJSObjectReference"] = "Success",
181+
["invokeNullJSObjectReference"] = "Success",
182+
["invokeNullFromVariableJSObjectReference"] = "Success",
183+
["invokeNonExistentJSObjectReference"] = "Success",
178184
};
179185

180186
// Include the sync assertions only when running under WebAssembly

src/Components/test/testassets/BasicTestApp/InteropComponent.razor

Lines changed: 125 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -167,33 +167,6 @@
167167
ReturnValues["invokeAsyncThrowsSerializingCircularStructure"] = $"Failure: {ex.Message}";
168168
}
169169

170-
try
171-
{
172-
var undefinedJsObjectReference = await JSRuntime.InvokeAsync<IJSObjectReference>("returnUndefined");
173-
ReturnValues["invokeAsyncThrowsUndefinedJSObjectReference"] = undefinedJsObjectReference is null ? "Failure: null" : "Failure: not null";
174-
}
175-
catch (JSException)
176-
{
177-
ReturnValues["invokeAsyncThrowsUndefinedJSObjectReference"] = "Success";
178-
}
179-
catch (Exception ex)
180-
{
181-
ReturnValues["invokeAsyncThrowsUndefinedJSObjectReference"] = $"Failure: {ex.Message}";
182-
}
183-
184-
try
185-
{
186-
var nullJsObjectReference = await JSRuntime.InvokeAsync<IJSObjectReference>("returnNull");
187-
ReturnValues["invokeAsyncThrowsNullJSObjectReference"] = nullJsObjectReference is null ? "Failure: null" : "Failure: not null";
188-
}
189-
catch (JSException)
190-
{
191-
ReturnValues["invokeAsyncThrowsNullJSObjectReference"] = "Success";
192-
}
193-
catch (Exception ex)
194-
{
195-
ReturnValues["invokeAsyncThrowsNullJSObjectReference"] = $"Failure: {ex.Message}";
196-
}
197170

198171
var jsObjectReference = await JSRuntime.InvokeAsync<IJSObjectReference>("returnJSObjectReference");
199172
ReturnValues["jsObjectReference.identity"] = await jsObjectReference.InvokeAsync<string>("identity", "Invoked from JSObjectReference");
@@ -308,6 +281,13 @@
308281
FunctionReferenceTests();
309282
}
310283

284+
await JSObjectReferenceAsyncTests();
285+
286+
if (shouldSupportSyncInterop)
287+
{
288+
JSObjectReferenceTests();
289+
}
290+
311291
Invocations = invocations;
312292
DoneWithInterop = true;
313293
}
@@ -394,34 +374,6 @@
394374
ReturnValues["invokeThrowsSerializingCircularStructure"] = $"Failure: {ex.Message}";
395375
}
396376

397-
try
398-
{
399-
var undefinedJsObjectReference = inProcRuntime.Invoke<IJSObjectReference>("returnUndefined");
400-
ReturnValues["invokeThrowsUndefinedJSObjectReference"] = undefinedJsObjectReference is null ? "Failure: null" : "Failure: not null";
401-
}
402-
catch (JSException)
403-
{
404-
ReturnValues["invokeThrowsUndefinedJSObjectReference"] = "Success";
405-
}
406-
catch (Exception ex)
407-
{
408-
ReturnValues["invokeThrowsUndefinedJSObjectReference"] = $"Failure: {ex.Message}";
409-
}
410-
411-
try
412-
{
413-
var nullJsObjectReference = inProcRuntime.Invoke<IJSObjectReference>("returnNull");
414-
ReturnValues["invokeThrowsNullJSObjectReference"] = nullJsObjectReference is null ? "Failure: null" : "Failure: not null";
415-
}
416-
catch (JSException)
417-
{
418-
ReturnValues["invokeThrowsNullJSObjectReference"] = "Success";
419-
}
420-
catch (Exception ex)
421-
{
422-
ReturnValues["invokeThrowsNullJSObjectReference"] = $"Failure: {ex.Message}";
423-
}
424-
425377
var jsInProcObjectReference = inProcRuntime.Invoke<IJSInProcessObjectReference>("returnJSObjectReference");
426378
ReturnValues["jsInProcessObjectReference.identity"] = jsInProcObjectReference.Invoke<string>("identity", "Invoked from JSInProcessObjectReference");
427379

@@ -626,6 +578,124 @@
626578
ReturnValues["changeFunctionViaObjectReference"] = testClassRef.Invoke<int>("getTextLength").ToString();
627579
}
628580

581+
private async Task JSObjectReferenceAsyncTests()
582+
{
583+
try
584+
{
585+
var undefinedJsObjectReference = await JSRuntime.InvokeAsync<IJSObjectReference>("jsInteropTests.returnUndefined");
586+
ReturnValues["invokeAsyncUndefinedJSObjectReference"] = undefinedJsObjectReference is null ? "Success" : $"Failure: not null (type: {undefinedJsObjectReference.GetType().FullName})";
587+
}
588+
catch (JSException ex)
589+
{
590+
ReturnValues["invokeAsyncUndefinedJSObjectReference"] = $"Failure: {ex.Message}";
591+
}
592+
catch (Exception ex)
593+
{
594+
ReturnValues["invokeAsyncUndefinedJSObjectReference"] = $"Failure: {ex.Message}";
595+
}
596+
597+
try
598+
{
599+
var nullJsObjectReference = await JSRuntime.InvokeAsync<IJSObjectReference>("jsInteropTests.returnNull");
600+
ReturnValues["invokeAsyncNullJSObjectReference"] = nullJsObjectReference is null ? "Success" : $"Failure: not null (type: {nullJsObjectReference.GetType().FullName})";
601+
}
602+
catch (JSException ex)
603+
{
604+
ReturnValues["invokeAsyncNullJSObjectReference"] = $"Failure: {ex.Message}";
605+
}
606+
catch (Exception ex)
607+
{
608+
ReturnValues["invokeAsyncNullJSObjectReference"] = $"Failure: {ex.Message}";
609+
}
610+
611+
try
612+
{
613+
var nullVariableJsObjectReference = await JSRuntime.GetValueAsync<IJSObjectReference>("jsInteropTests.testObject.nullProperty");
614+
ReturnValues["invokeAsyncNullFromVariableJSObjectReference"] = nullVariableJsObjectReference is null ? "Success" : $"Failure: not null (type: {nullVariableJsObjectReference.GetType().FullName})";
615+
}
616+
catch (JSException ex)
617+
{
618+
ReturnValues["invokeAsyncNullFromVariableJSObjectReference"] = $"Failure: {ex.Message}";
619+
}
620+
catch (Exception ex)
621+
{
622+
ReturnValues["invokeAsyncNullFromVariableJSObjectReference"] = $"Failure: {ex.Message}";
623+
}
624+
625+
try
626+
{
627+
await JSRuntime.GetValueAsync<IJSObjectReference>("nonexistend");
628+
}
629+
catch (JSException)
630+
{
631+
ReturnValues["invokeAsyncNonExistentJSObjectReference"] = "Success";
632+
}
633+
catch (Exception ex)
634+
{
635+
ReturnValues["invokeAsyncNonExistentJSObjectReference"] = $"Failure: {ex.Message}";
636+
}
637+
}
638+
639+
private void JSObjectReferenceTests()
640+
{
641+
var inProcRuntime = ((IJSInProcessRuntime)JSRuntime);
642+
643+
try
644+
{
645+
var undefinedJsObjectReference = inProcRuntime.Invoke<IJSObjectReference>("returnUndefined");
646+
ReturnValues["invokeUndefinedJSObjectReference"] = undefinedJsObjectReference is null ? "Success" : $"Failure: not null (type: {undefinedJsObjectReference.GetType().FullName})";
647+
}
648+
catch (JSException ex)
649+
{
650+
ReturnValues["invokeUndefinedJSObjectReference"] = $"Failure: {ex.Message}";
651+
}
652+
catch (Exception ex)
653+
{
654+
ReturnValues["invokeUndefinedJSObjectReference"] = $"Failure: {ex.Message}";
655+
}
656+
657+
try
658+
{
659+
var nullJsObjectReference = inProcRuntime.Invoke<IJSObjectReference>("returnNull");
660+
ReturnValues["invokeNullJSObjectReference"] = nullJsObjectReference is null ? "Success" : $"Failure: not null (type: {nullJsObjectReference.GetType().FullName})";
661+
}
662+
catch (JSException ex)
663+
{
664+
ReturnValues["invokeNullJSObjectReference"] = $"Failure: {ex.Message}";
665+
}
666+
catch (Exception ex)
667+
{
668+
ReturnValues["invokeNullJSObjectReference"] = $"Failure: {ex.Message}";
669+
}
670+
671+
try
672+
{
673+
var nullVariableJsObjectReference = inProcRuntime.GetValue<IJSObjectReference>("jsInteropTests.testObject.nullProperty");
674+
ReturnValues["invokeNullFromVariableJSObjectReference"] = nullVariableJsObjectReference is null ? "Success" : $"Failure: not null (type: {nullVariableJsObjectReference.GetType().FullName})";
675+
}
676+
catch (JSException ex)
677+
{
678+
ReturnValues["invokeNullFromVariableJSObjectReference"] = $"Failure: {ex.Message}";
679+
}
680+
catch (Exception ex)
681+
{
682+
ReturnValues["invokeNullFromVariableJSObjectReference"] = $"Failure: {ex.Message}";
683+
}
684+
685+
try
686+
{
687+
inProcRuntime.GetValue<IJSObjectReference>("nonexistend");
688+
}
689+
catch (JSException)
690+
{
691+
ReturnValues["invokeNonExistentJSObjectReference"] = "Success";
692+
}
693+
catch (Exception ex)
694+
{
695+
ReturnValues["invokeNonExistentJSObjectReference"] = $"Failure: {ex.Message}";
696+
}
697+
}
698+
629699
public class PassDotNetObjectByRefArgs
630700
{
631701
public string StringValue { get; set; }

src/Components/test/testassets/BasicTestApp/wwwroot/js/jsinteroptests.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,8 @@ const testObject = {
234234
},
235235
set setOnlyProperty(value) {
236236
this.num = value;
237-
}
237+
},
238+
nullProperty: null
238239
}
239240

240241
window.jsInteropTests = {

src/JSInterop/Microsoft.JSInterop.JS/src/src/Microsoft.JSInterop.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,12 @@ export module DotNet {
155155
* @throws Error if the given value is not an Object.
156156
*/
157157
export function createJSObjectReference(jsObject: any): any {
158+
if (jsObject === null || jsObject === undefined) {
159+
return {
160+
[jsObjectIdKey]: -1
161+
};
162+
}
163+
158164
if (jsObject && (typeof jsObject === "object" || jsObject instanceof Function)) {
159165
cachedJSObjectsById[nextJsObjectId] = new JSObject(jsObject);
160166

@@ -220,7 +226,7 @@ export module DotNet {
220226
export function disposeJSObjectReference(jsObjectReference: any): void {
221227
const id = jsObjectReference && jsObjectReference[jsObjectIdKey];
222228

223-
if (typeof id === "number") {
229+
if (typeof id === "number" && id !== -1) {
224230
disposeJSObjectReferenceById(id);
225231
}
226232
}

src/JSInterop/Microsoft.JSInterop.JS/src/test/CallDispatcher.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,4 +395,55 @@ describe("CallDispatcher", () => {
395395

396396
expect(result2).toBe("30");
397397
});
398+
399+
test("createJSObjectReference: Handles null values without throwing", () => {
400+
const nullRef = DotNet.createJSObjectReference(null);
401+
expect(nullRef).toEqual({ [jsObjectId]: -1 });
402+
});
403+
404+
test("createJSObjectReference: Handles undefined values without throwing", () => {
405+
const undefinedRef = DotNet.createJSObjectReference(undefined);
406+
expect(undefinedRef).toEqual({ [jsObjectId]: -1 });
407+
});
408+
409+
test("disposeJSObjectReference: Safely handles null reference disposal", () => {
410+
const nullRef = DotNet.createJSObjectReference(null);
411+
expect(() => DotNet.disposeJSObjectReference(nullRef)).not.toThrow();
412+
});
413+
414+
test("createJSObjectReference: Still throws for invalid types", () => {
415+
expect(() => DotNet.createJSObjectReference("string")).toThrow();
416+
expect(() => DotNet.createJSObjectReference(123)).toThrow();
417+
expect(() => DotNet.createJSObjectReference(true)).toThrow();
418+
});
419+
420+
test("GetValue: Returns JSObjectReference with sentinel value for null property", () => {
421+
const testObject = { nullProp: null };
422+
const objectId = getObjectReferenceId(testObject);
423+
424+
const result = dispatcher.invokeJSFromDotNet(
425+
"nullProp",
426+
"[]",
427+
DotNet.JSCallResultType.JSObjectReference,
428+
objectId,
429+
DotNet.JSCallType.GetValue
430+
);
431+
432+
expect(result).toBe('{"__jsObjectId":-1}');
433+
});
434+
435+
test("GetValue: Returns JSObjectReference with sentinel value for undefined property", () => {
436+
const testObject = { undefinedProp: undefined };
437+
const objectId = getObjectReferenceId(testObject);
438+
439+
const result = dispatcher.invokeJSFromDotNet(
440+
"undefinedProp",
441+
"[]",
442+
DotNet.JSCallResultType.JSObjectReference,
443+
objectId,
444+
DotNet.JSCallType.GetValue
445+
);
446+
447+
expect(result).toBe('{"__jsObjectId":-1}');
448+
});
398449
});

src/JSInterop/Microsoft.JSInterop/src/Infrastructure/JSObjectReferenceJsonConverter.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ public override bool CanConvert(Type typeToConvert)
2222
public override IJSObjectReference? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
2323
{
2424
var id = JSObjectReferenceJsonWorker.ReadJSObjectReferenceIdentifier(ref reader);
25+
26+
if (id == -1)
27+
{
28+
return null;
29+
}
30+
2531
return new JSObjectReference(_jsRuntime, id);
2632
}
2733

0 commit comments

Comments
 (0)