diff --git a/NativeScript/runtime/ArgConverter.mm b/NativeScript/runtime/ArgConverter.mm index bd6956c6..96cded6a 100644 --- a/NativeScript/runtime/ArgConverter.mm +++ b/NativeScript/runtime/ArgConverter.mm @@ -883,16 +883,18 @@ ObjCDataWrapper* objcDataWrapper = static_cast(wrapper); id target = objcDataWrapper->Data(); - if (![target isKindOfClass:[NSArray class]]) { + // Treat anything that can report a count and return elements by index as + // indexable, not just NSArray (e.g. PHFetchResult, NSOrderedSet). + if (![target respondsToSelector:@selector(count)] || + ![target respondsToSelector:@selector(objectAtIndex:)]) { return; } - NSArray* array = (NSArray*)target; - if (index >= [array count]) { + if (index >= [target count]) { return; } - id obj = [array objectAtIndex:index]; + id obj = [target objectAtIndex:index]; std::shared_ptr cache = Caches::Get(isolate); auto it = cache->Instances.find(obj); @@ -959,6 +961,16 @@ ObjCDataWrapper* objcDataWrapper = static_cast(wrapper); id target = objcDataWrapper->Data(); if (![target isKindOfClass:[NSMutableArray class]]) { + // Indexed assignment is only supported for NSMutableArray. Other indexable + // collections (e.g. NSOrderedSet, PHFetchResult) reject the write so it can't be + // silently dropped into an unreachable JS property on the wrapper. NSArray is + // excluded from the throw and falls through to a silent no-op. + if ([target respondsToSelector:@selector(count)] && + [target respondsToSelector:@selector(objectAtIndex:)] && + ![target isKindOfClass:[NSArray class]]) { + isolate->ThrowException(Exception::TypeError( + tns::ToV8String(isolate, "Indexed assignment is only supported for NSMutableArray"))); + } return; } diff --git a/TestRunner/app/tests/ApiTests.js b/TestRunner/app/tests/ApiTests.js index 84834d17..d67b6e5d 100644 --- a/TestRunner/app/tests/ApiTests.js +++ b/TestRunner/app/tests/ApiTests.js @@ -22,6 +22,45 @@ describe(module.id, function () { expect(res[0].constructor.name).toEqual("NSURL"); }); + it("indexed access works for non-NSArray indexable collections", function () { + // NSOrderedSet is not an NSArray but responds to count + objectAtIndex:, + // so obj[i] should resolve the same elements as objectAtIndex(i). + const set = NSOrderedSet.orderedSetWithArray(['a', 'b', 'c']); + expect(set.count).toBe(3); + expect(set[0]).toBe(set.objectAtIndex(0)); + expect(set[1]).toBe(set.objectAtIndex(1)); + expect(set[2]).toBe(set.objectAtIndex(2)); + expect(set[0]).toBe('a'); + expect(set[2]).toBe('c'); + // Out-of-range index returns undefined instead of throwing. + expect(set[3]).toBeUndefined(); + }); + + it("indexed assignment throws for read-only indexable collections", function () { + const set = NSOrderedSet.orderedSetWithArray(['a', 'b', 'c']); + // NSOrderedSet is indexable but not writable, so indexed assignment throws. + // The index is irrelevant; the whole collection rejects writes. + expect(function () { set[0] = 'ghost'; }).toThrowError(TypeError, /Indexed assignment is only supported for NSMutableArray/); + expect(function () { set[5] = 'ghost'; }).toThrowError(TypeError); + // The rejected out-of-range write left no stray JS property behind, the native + // collection is untouched, and reads still resolve native elements. + expect(Object.prototype.hasOwnProperty.call(set, '5')).toBe(false); + expect(set.count).toBe(3); + expect(set[0]).toBe('a'); + expect(set[5]).toBeUndefined(); + }); + + it("indexed assignment still works for NSMutableArray", function () { + const first = NSObject.new(); + const arr = NSMutableArray.arrayWithArray([first, NSObject.new()]); + const replacement = NSObject.new(); + + arr[0] = replacement; + expect(arr.count).toBe(2); + expect(arr.objectAtIndex(0)).toBe(replacement); + expect(arr[0]).toBe(replacement); + }); + it("MethodCalledInDealloc", function () { expect(function () { (function () {