diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 334787b..84bc177 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,3 +163,28 @@ jobs: - name: Build example for iOS run: | yarn turbo run build:ios --cache-dir="${{ env.TURBO_CACHE_DIR }}" + + test-ios: + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup + uses: ./.github/actions/setup + + - name: Pick an iPhone simulator + run: | + UDID=$(xcrun simctl list devices available | grep -m1 -oE 'iPhone[^(]*\([0-9A-F-]{36}\)' | grep -oE '[0-9A-F-]{36}') + echo "IOS_DESTINATION=id=$UDID" >> "$GITHUB_ENV" + + - name: Run Swift core tests + run: yarn test:ios + + - name: Install cocoapods + run: | + cd example/ios + pod install + + - name: Run native tests hosted in the example app + run: yarn test:ios:example diff --git a/.gitignore b/.gitignore index 7acd0f8..0a942a1 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ jsconfig.json # Xcode # build/ +ios-tests/.build/ +ios-tests/.swiftpm/ *.pbxuser !default.pbxuser *.mode1v3 diff --git a/example/ios/AutoSkeletonExample.xcodeproj/project.pbxproj b/example/ios/AutoSkeletonExample.xcodeproj/project.pbxproj index 49a23b4..93c1ef6 100644 --- a/example/ios/AutoSkeletonExample.xcodeproj/project.pbxproj +++ b/example/ios/AutoSkeletonExample.xcodeproj/project.pbxproj @@ -9,22 +9,41 @@ /* Begin PBXBuildFile section */ 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 1BBD1C960118A48349FDBFA6 /* libPods-AutoSkeletonExample.a in Frameworks */ = {isa = PBXBuildFile; fileRef = A6FE202C403EFAE2D69DE4C6 /* libPods-AutoSkeletonExample.a */; }; + 217E1F6524FCDAC0A16F709F /* libPods-AutoSkeletonExampleTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 093A718BB19335CD078DD231 /* libPods-AutoSkeletonExampleTests.a */; }; + 64D7A9FA8D9C7AADB9EAF59A /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9194C1DFBB980F7421FD53BE /* Foundation.framework */; }; 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; + 9AA9391065FDB14244B1EF3C /* AutoSkeletonViewTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = A40F661816911C062B22E12B /* AutoSkeletonViewTests.mm */; }; C090EC147B18AC3F6301659F /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; }; FA18FF012D90C63B00432021 /* File.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA18FF002D90C63B00432021 /* File.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 59DF82BEA95DA4E57CB09831 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 13B07F861A680F5B00A75B9A; + remoteInfo = AutoSkeletonExample; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ + 093A718BB19335CD078DD231 /* libPods-AutoSkeletonExampleTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-AutoSkeletonExampleTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07F961A680F5B00A75B9A /* AutoSkeletonExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AutoSkeletonExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = AutoSkeletonExample/Images.xcassets; sourceTree = ""; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = AutoSkeletonExample/Info.plist; sourceTree = ""; }; 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = AutoSkeletonExample/PrivacyInfo.xcprivacy; sourceTree = ""; }; + 336A408DCB9915B1C177D7FD /* Pods-AutoSkeletonExampleTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AutoSkeletonExampleTests.debug.xcconfig"; path = "Target Support Files/Pods-AutoSkeletonExampleTests/Pods-AutoSkeletonExampleTests.debug.xcconfig"; sourceTree = ""; }; 3B4392A12AC88292D35C810B /* Pods-AutoSkeletonExample.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AutoSkeletonExample.debug.xcconfig"; path = "Target Support Files/Pods-AutoSkeletonExample/Pods-AutoSkeletonExample.debug.xcconfig"; sourceTree = ""; }; 5709B34CF0A7D63546082F79 /* Pods-AutoSkeletonExample.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AutoSkeletonExample.release.xcconfig"; path = "Target Support Files/Pods-AutoSkeletonExample/Pods-AutoSkeletonExample.release.xcconfig"; sourceTree = ""; }; 761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = AutoSkeletonExample/AppDelegate.swift; sourceTree = ""; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = AutoSkeletonExample/LaunchScreen.storyboard; sourceTree = ""; }; + 9194C1DFBB980F7421FD53BE /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; + 98C39D7BC9428BE2274855BE /* AutoSkeletonExampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AutoSkeletonExampleTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + A40F661816911C062B22E12B /* AutoSkeletonViewTests.mm */ = {isa = PBXFileReference; includeInIndex = 1; path = AutoSkeletonViewTests.mm; sourceTree = ""; }; A6FE202C403EFAE2D69DE4C6 /* libPods-AutoSkeletonExample.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-AutoSkeletonExample.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + CC0AE457AA2AD80297768FFF /* Pods-AutoSkeletonExampleTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AutoSkeletonExampleTests.release.xcconfig"; path = "Target Support Files/Pods-AutoSkeletonExampleTests/Pods-AutoSkeletonExampleTests.release.xcconfig"; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; FA18FF002D90C63B00432021 /* File.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = File.swift; sourceTree = ""; }; FA18FF022D90C65F00432021 /* AutoSkeletonExample-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "AutoSkeletonExample-Bridging-Header.h"; sourceTree = ""; }; @@ -39,6 +58,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A0D9FC514F84AC47C99F4CC7 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 64D7A9FA8D9C7AADB9EAF59A /* Foundation.framework in Frameworks */, + 217E1F6524FCDAC0A16F709F /* libPods-AutoSkeletonExampleTests.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -61,10 +89,21 @@ children = ( ED297162215061F000B7C4FE /* JavaScriptCore.framework */, A6FE202C403EFAE2D69DE4C6 /* libPods-AutoSkeletonExample.a */, + F329F1BB80E9E7A3F3D62E10 /* iOS */, + 093A718BB19335CD078DD231 /* libPods-AutoSkeletonExampleTests.a */, ); name = Frameworks; sourceTree = ""; }; + 31310E7F3CB09FB572A54060 /* AutoSkeletonExampleTests */ = { + isa = PBXGroup; + children = ( + A40F661816911C062B22E12B /* AutoSkeletonViewTests.mm */, + ); + name = AutoSkeletonExampleTests; + path = AutoSkeletonExampleTests; + sourceTree = ""; + }; 832341AE1AAA6A7D00B99B32 /* Libraries */ = { isa = PBXGroup; children = ( @@ -80,6 +119,7 @@ 83CBBA001A601CBA00E9B192 /* Products */, 2D16E6871FA4F8E400B85C8A /* Frameworks */, BBD78D7AC51CEA395F1C20DB /* Pods */, + 31310E7F3CB09FB572A54060 /* AutoSkeletonExampleTests */, ); indentWidth = 2; sourceTree = ""; @@ -90,6 +130,7 @@ isa = PBXGroup; children = ( 13B07F961A680F5B00A75B9A /* AutoSkeletonExample.app */, + 98C39D7BC9428BE2274855BE /* AutoSkeletonExampleTests.xctest */, ); name = Products; sourceTree = ""; @@ -99,10 +140,20 @@ children = ( 3B4392A12AC88292D35C810B /* Pods-AutoSkeletonExample.debug.xcconfig */, 5709B34CF0A7D63546082F79 /* Pods-AutoSkeletonExample.release.xcconfig */, + CC0AE457AA2AD80297768FFF /* Pods-AutoSkeletonExampleTests.release.xcconfig */, + 336A408DCB9915B1C177D7FD /* Pods-AutoSkeletonExampleTests.debug.xcconfig */, ); path = Pods; sourceTree = ""; }; + F329F1BB80E9E7A3F3D62E10 /* iOS */ = { + isa = PBXGroup; + children = ( + 9194C1DFBB980F7421FD53BE /* Foundation.framework */, + ); + name = iOS; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -127,6 +178,25 @@ productReference = 13B07F961A680F5B00A75B9A /* AutoSkeletonExample.app */; productType = "com.apple.product-type.application"; }; + 913176E6D028B29379806C68 /* AutoSkeletonExampleTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8368E4421051201E6F2D42D4 /* Build configuration list for PBXNativeTarget "AutoSkeletonExampleTests" */; + buildPhases = ( + DE82905AE2D1A5F817590B20 /* [CP] Check Pods Manifest.lock */, + 5ADFC514898E1EDA5C47A630 /* Sources */, + A0D9FC514F84AC47C99F4CC7 /* Frameworks */, + 55E8238BE0B16574097816F9 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 2EFD0ABFEEE9A88F4370A70C /* PBXTargetDependency */, + ); + name = AutoSkeletonExampleTests; + productName = AutoSkeletonExampleTests; + productReference = 98C39D7BC9428BE2274855BE /* AutoSkeletonExampleTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -154,6 +224,7 @@ projectRoot = ""; targets = ( 13B07F861A680F5B00A75B9A /* AutoSkeletonExample */, + 913176E6D028B29379806C68 /* AutoSkeletonExampleTests */, ); }; /* End PBXProject section */ @@ -169,6 +240,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 55E8238BE0B16574097816F9 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -227,6 +305,28 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + DE82905AE2D1A5F817590B20 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-AutoSkeletonExampleTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; E235C05ADACE081382539298 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -256,8 +356,25 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5ADFC514898E1EDA5C47A630 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 9AA9391065FDB14244B1EF3C /* AutoSkeletonViewTests.mm in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 2EFD0ABFEEE9A88F4370A70C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = AutoSkeletonExample; + target = 13B07F861A680F5B00A75B9A /* AutoSkeletonExample */; + targetProxy = 59DF82BEA95DA4E57CB09831 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 13B07F941A680F5B00A75B9A /* Debug */ = { isa = XCBuildConfiguration; @@ -491,6 +608,39 @@ }; name = Release; }; + 872306C3800130B990BE8086 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = CC0AE457AA2AD80297768FFF /* Pods-AutoSkeletonExampleTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGNING_ALLOWED = NO; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + PRODUCT_BUNDLE_IDENTIFIER = autoskeleton.example.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/AutoSkeletonExample.app/AutoSkeletonExample"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + B50E2EAC3DC96A361D72FFDB /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 336A408DCB9915B1C177D7FD /* Pods-AutoSkeletonExampleTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGNING_ALLOWED = NO; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + PRODUCT_BUNDLE_IDENTIFIER = autoskeleton.example.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/AutoSkeletonExample.app/AutoSkeletonExample"; + }; + name = Debug; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -503,6 +653,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 8368E4421051201E6F2D42D4 /* Build configuration list for PBXNativeTarget "AutoSkeletonExampleTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 872306C3800130B990BE8086 /* Release */, + B50E2EAC3DC96A361D72FFDB /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "AutoSkeletonExample" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/example/ios/AutoSkeletonExample.xcodeproj/xcshareddata/xcschemes/AutoSkeletonExample.xcscheme b/example/ios/AutoSkeletonExample.xcodeproj/xcshareddata/xcschemes/AutoSkeletonExample.xcscheme index f94d0c8..e81f2bf 100644 --- a/example/ios/AutoSkeletonExample.xcodeproj/xcshareddata/xcschemes/AutoSkeletonExample.xcscheme +++ b/example/ios/AutoSkeletonExample.xcodeproj/xcshareddata/xcschemes/AutoSkeletonExample.xcscheme @@ -38,6 +38,16 @@ ReferencedContainer = "container:AutoSkeletonExample.xcodeproj"> + + + + +#import + +#import +#import + +using namespace facebook::react; + +// The library's headers are not public in its podspec, so classes are resolved at runtime. +static Class AutoSkeletonViewClass(void) { + return NSClassFromString(@"AutoSkeletonView"); +} + +static UIView *MakeLoadingSkeleton(void) { + UIView *skeleton = + [[AutoSkeletonViewClass() alloc] initWithFrame:CGRectMake(0, 0, 300, 300)]; + auto oldProps = std::make_shared(); + auto props = std::make_shared(); + props->isLoading = true; + [skeleton updateProps:props oldProps:oldProps]; + return skeleton; +} + +// A paragraph is always a skeleton target, whatever its background. +static UIView *MakeTextChild(void) { + UIView *child = + [[NSClassFromString(@"RCTParagraphComponentView") alloc] initWithFrame:CGRectMake(0, 0, 100, 20)]; + return child; +} + +static void Mount(UIView *skeleton, UIView *child) { + [skeleton mountChildComponentView:child index:0]; + [skeleton setNeedsLayout]; + [skeleton layoutIfNeeded]; +} + +@interface AutoSkeletonViewTests : XCTestCase +@end + +@implementation AutoSkeletonViewTests + +#pragma mark - Fabric + +- (void)testComponentClassesExist { + XCTAssertNotNil(AutoSkeletonViewClass()); + XCTAssertNotNil(NSClassFromString(@"AutoSkeletonIgnoreView")); +} + +// A child unmounted while loading goes to Fabric's recycle pool, which never resets `hidden` (#19). +- (void)testUnmountingAChildWhileLoadingRestoresItsVisibility { + UIView *skeleton = MakeLoadingSkeleton(); + UIView *child = MakeTextChild(); + Mount(skeleton, child); + XCTAssertTrue(child.hidden, @"precondition: the skeleton hides its child while loading"); + + [skeleton unmountChildComponentView:child index:0]; + + XCTAssertFalse(child.hidden); + XCTAssertNil(child.superview); +} + +- (void)testUnmountingAnOriginallyHiddenChildKeepsItHidden { + UIView *skeleton = MakeLoadingSkeleton(); + UIView *child = MakeTextChild(); + child.hidden = YES; + Mount(skeleton, child); + + [skeleton unmountChildComponentView:child index:0]; + + XCTAssertTrue(child.hidden); +} + +// Covers the whole skeleton being unmounted while loading: it leaves the window first. +- (void)testLeavingTheWindowWhileLoadingRestoresChildren { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 400, 800)]; + UIView *skeleton = MakeLoadingSkeleton(); + [window addSubview:skeleton]; + UIView *child = MakeTextChild(); + Mount(skeleton, child); + XCTAssertTrue(child.hidden, @"precondition: the skeleton hides its child while loading"); + + [skeleton removeFromSuperview]; + + XCTAssertFalse(child.hidden); +} + +#pragma mark - Old Architecture manager + +// `RCTComponentData` exports this type to JS; only `UIColorArray` makes the view config run +// processColorArray, so anything else sends raw color strings to native (#20). +- (void)testOldArchGradientColorsPropIsExportedAsUIColorArray { + Class manager = NSClassFromString(@"AutoSkeletonViewManager"); + NSArray *config = + ((NSArray * (*)(id, SEL)) objc_msgSend)(manager, NSSelectorFromString(@"propConfig_gradientColors")); + + XCTAssertEqualObjects(config.firstObject, @"UIColorArray"); +} + +- (void)testOldArchSetterAppliesProcessedColors { + UIView *view = [self setOldArchGradientColors:@[ @(0xFFFF0000), @(0xFF0000FF) ]]; + + NSArray *colors = [view valueForKey:@"gradientColors"]; + XCTAssertEqual(colors.count, 2u); + XCTAssertEqualObjects(colors[0], [UIColor colorWithRed:1 green:0 blue:0 alpha:1]); + XCTAssertEqualObjects(colors[1], [UIColor colorWithRed:0 green:0 blue:1 alpha:1]); +} + +// Unprocessed strings fail RCTConvert, leaving an empty array that used to be indexed (#20). +- (void)testOldArchSetterSurvivesUnprocessedColors { + NSArray *unprocessed = @[ @"#D3D3D3", @"#FFFFFF" ]; + + XCTAssertNoThrow([self setOldArchGradientColors:unprocessed]); +} + +- (UIView *)setOldArchGradientColors:(id)json { + id manager = [NSClassFromString(@"AutoSkeletonViewManager") new]; + UIView *view = [NSClassFromString(@"react_native_auto_skeleton.SkeletonViewOldArch") new]; + UIView *defaultView = [NSClassFromString(@"react_native_auto_skeleton.SkeletonViewOldArch") new]; + ((void (*)(id, SEL, id, id, id))objc_msgSend)( + manager, NSSelectorFromString(@"set_gradientColors:forView:withDefaultView:"), json, view, defaultView); + return view; +} + +@end diff --git a/example/ios/Podfile b/example/ios/Podfile index ebefb9c..23ba304 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -25,6 +25,12 @@ target 'AutoSkeletonExample' do :app_path => "#{Pod::Config.instance.installation_root}/.." ) + # Native tests hosted in the example app, so they run against the real React and codegen pods. + # Headers only: linking the pods again would duplicate every class already in the host app. + target 'AutoSkeletonExampleTests' do + inherit! :search_paths + end + # Run Codegen during development for the example app. pre_install do |installer| diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index cd55132..d4201c9 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1831,6 +1831,6 @@ SPEC CHECKSUMS: SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: b94ff2d7559cb66837c97a3f394ddb817faad4d6 -PODFILE CHECKSUM: c17a16e5e31afe1db0623578254277ab7a4ff928 +PODFILE CHECKSUM: e7e63ca1c76b906666b2df55968a6841de6aaaa0 COCOAPODS: 1.16.2 diff --git a/ios-tests/Package.swift b/ios-tests/Package.swift new file mode 100644 index 0000000..3646abd --- /dev/null +++ b/ios-tests/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version:5.9 +// Unit tests for the architecture-agnostic Swift core in ios/SkeletonView. +// Run with `yarn test:ios`. +import PackageDescription + +let package = Package( + name: "AutoSkeletonNativeTests", + platforms: [.iOS(.v15)], + targets: [ + .target( + name: "SkeletonView", + path: "Sources/SkeletonView", + // The Fabric subclass imports React, which is not available outside the app build. + exclude: ["SkeletonViewFabric.swift"] + ), + .testTarget( + name: "SkeletonViewTests", + dependencies: ["SkeletonView"], + path: "Tests/SkeletonViewTests" + ), + ] +) diff --git a/ios-tests/Sources/SkeletonView b/ios-tests/Sources/SkeletonView new file mode 120000 index 0000000..e456f16 --- /dev/null +++ b/ios-tests/Sources/SkeletonView @@ -0,0 +1 @@ +../../ios/SkeletonView \ No newline at end of file diff --git a/ios-tests/Tests/SkeletonViewTests/AnimationGradientTests.swift b/ios-tests/Tests/SkeletonViewTests/AnimationGradientTests.swift new file mode 100644 index 0000000..359547b --- /dev/null +++ b/ios-tests/Tests/SkeletonViewTests/AnimationGradientTests.swift @@ -0,0 +1,46 @@ +import UIKit +import XCTest +@testable import SkeletonView + +final class AnimationGradientTests: XCTestCase { + private func gradientColors(of core: SkeletonCore) -> [CGColor] { + let gradient = core.mainLayer.sublayers?.compactMap { $0 as? CAGradientLayer }.first + let animation = gradient?.animation(forKey: "alphaGradientAnimation") as? CABasicAnimation + return (animation?.fromValue as? [CGColor]) ?? [] + } + + private func expected(_ a: UIColor, _ b: UIColor) -> [CGColor] { + [a.cgColor, b.cgColor, a.cgColor] + } + + func testUsesTheTwoProvidedColors() { + let core = SkeletonCore(frame: CGRect(x: 0, y: 0, width: 300, height: 400)) + + core.gradientColors = [.red, .blue] + + XCTAssertEqual(gradientColors(of: core), expected(.red, .blue)) + } + + // The Old Arch setter receives an empty NSArray when RCTConvert drops every color (#20). + func testEmptyColorsFromObjectiveCFallBackToDefaults() { + let core = SkeletonCore(frame: CGRect(x: 0, y: 0, width: 300, height: 400)) + + core.perform(NSSelectorFromString("setGradientColors:"), with: NSMutableArray()) + + XCTAssertEqual( + gradientColors(of: core), + expected(DEFAULT_GRADIENT_COLORS[0], DEFAULT_GRADIENT_COLORS[1]) + ) + } + + func testASingleColorFallsBackToDefaults() { + let core = SkeletonCore(frame: CGRect(x: 0, y: 0, width: 300, height: 400)) + + core.gradientColors = [.red] + + XCTAssertEqual( + gradientColors(of: core), + expected(DEFAULT_GRADIENT_COLORS[0], DEFAULT_GRADIENT_COLORS[1]) + ) + } +} diff --git a/ios-tests/Tests/SkeletonViewTests/SkeletonCoreTests.swift b/ios-tests/Tests/SkeletonViewTests/SkeletonCoreTests.swift new file mode 100644 index 0000000..0ab6f92 --- /dev/null +++ b/ios-tests/Tests/SkeletonViewTests/SkeletonCoreTests.swift @@ -0,0 +1,101 @@ +import UIKit +import XCTest +@testable import SkeletonView + +final class SkeletonCoreTests: XCTestCase { + private func makeView(_ frame: CGRect, background: UIColor? = .red) -> UIView { + let view = UIView(frame: frame) + view.backgroundColor = background + return view + } + + private func makeCore(children: [UIView]) -> SkeletonCore { + let core = SkeletonCore(frame: CGRect(x: 0, y: 0, width: 300, height: 400)) + children.forEach(core.addSubview) + core.initOriginalViews(subviews: children) + return core + } + + func testHidesSkeletonTargetsWhileLoading() { + let target = makeView(CGRect(x: 0, y: 0, width: 100, height: 20)) + let transparent = makeView(CGRect(x: 0, y: 30, width: 100, height: 20), background: nil) + let core = makeCore(children: [target, transparent]) + + core.isLoading = true + + XCTAssertTrue(target.isHidden) + XCTAssertFalse(transparent.isHidden) + } + + func testNeverHidesIgnoredViews() { + let ignored = makeView(CGRect(x: 0, y: 0, width: 100, height: 20)) + ignored.accessibilityIdentifier = Constants.IGNORE_VIEW_NAME + let core = makeCore(children: [ignored]) + + core.isLoading = true + + XCTAssertFalse(ignored.isHidden) + } + + func testRestoresTheOriginalHiddenStateWhenLoadingEnds() { + let visible = makeView(CGRect(x: 0, y: 0, width: 100, height: 20)) + let hidden = makeView(CGRect(x: 0, y: 30, width: 100, height: 20)) + hidden.isHidden = true + let core = makeCore(children: [visible, hidden]) + + core.isLoading = true + XCTAssertTrue(visible.isHidden) + + core.isLoading = false + XCTAssertFalse(visible.isHidden) + XCTAssertTrue(hidden.isHidden) + } + + // A view unmounted during loading goes to Fabric's recycle pool; it must not stay hidden (#18, #19). + func testRestoreOriginalViewUnhidesAViewLeavingTheSkeleton() { + let leaving = makeView(CGRect(x: 0, y: 0, width: 100, height: 20)) + let staying = makeView(CGRect(x: 0, y: 30, width: 100, height: 20)) + let core = makeCore(children: [leaving, staying]) + core.isLoading = true + + core.restoreOriginalView(leaving) + + XCTAssertFalse(leaving.isHidden) + XCTAssertTrue(staying.isHidden) + XCTAssertFalse(core.views.contains { $0 === leaving }) + } + + func testRestoreOriginalViewKeepsAnOriginallyHiddenViewHidden() { + let hidden = makeView(CGRect(x: 0, y: 0, width: 100, height: 20)) + hidden.isHidden = true + let core = makeCore(children: [hidden]) + core.isLoading = true + + core.restoreOriginalView(hidden) + + XCTAssertTrue(hidden.isHidden) + } + + // Once handed back, the view belongs to its next owner; ending the skeleton must not touch it. + func testARestoredViewIsNoLongerManaged() { + let leaving = makeView(CGRect(x: 0, y: 0, width: 100, height: 20)) + let core = makeCore(children: [leaving]) + core.isLoading = true + core.restoreOriginalView(leaving) + + leaving.isHidden = true + core.isLoading = false + + XCTAssertTrue(leaving.isHidden) + } + + func testRestoreOriginalViewIgnoresViewsItNeverHid() { + let core = makeCore(children: []) + let foreign = makeView(CGRect(x: 0, y: 0, width: 100, height: 20)) + foreign.isHidden = true + + core.restoreOriginalView(foreign) + + XCTAssertTrue(foreign.isHidden) + } +} diff --git a/ios-tests/Tests/SkeletonViewTests/SkeletonPlaceholderMaskTests.swift b/ios-tests/Tests/SkeletonViewTests/SkeletonPlaceholderMaskTests.swift new file mode 100644 index 0000000..5daf924 --- /dev/null +++ b/ios-tests/Tests/SkeletonViewTests/SkeletonPlaceholderMaskTests.swift @@ -0,0 +1,45 @@ +import UIKit +import XCTest +@testable import SkeletonView + +final class SkeletonPlaceholderMaskTests: XCTestCase { + private func maskPath(of core: SkeletonCore) -> CGPath? { + (core.mainLayer.mask as? CAShapeLayer)?.path + } + + private func makeView(_ frame: CGRect) -> UIView { + let view = UIView(frame: frame) + view.backgroundColor = .red + return view + } + + func testDirectChildShapeMatchesItsFrame() { + let core = SkeletonCore(frame: CGRect(x: 0, y: 0, width: 300, height: 400)) + let child = makeView(CGRect(x: 10, y: 20, width: 100, height: 30)) + core.addSubview(child) + core.initOriginalViews(subviews: [child]) + + core.isLoading = true + + XCTAssertEqual(maskPath(of: core)?.boundingBox, child.frame) + } + + // Old Arch collects nested descendants; their shape must land where they are on screen (#16). + func testNestedViewShapeIsPlacedInSkeletonCoordinates() { + let core = SkeletonCore(frame: CGRect(x: 0, y: 0, width: 300, height: 400)) + let card = makeView(CGRect(x: 0, y: 150, width: 300, height: 120)) + let nested = makeView(CGRect(x: 200, y: 30, width: 100, height: 40)) + card.addSubview(nested) + core.addSubview(card) + core.initOriginalViews(subviews: [card, nested]) + + core.isLoading = true + + let path = try! XCTUnwrap(maskPath(of: core)) + // Centre of `nested` on screen: inside the card. + XCTAssertTrue(path.contains(CGPoint(x: 250, y: 200))) + // Centre of `nested` read as if its local frame were skeleton coordinates: above the card. + XCTAssertFalse(path.contains(CGPoint(x: 250, y: 50))) + XCTAssertEqual(path.boundingBox, card.frame) + } +} diff --git a/ios/React/AutoSkeletonView.mm b/ios/React/AutoSkeletonView.mm index 33e23eb..e7b0fb8 100644 --- a/ios/React/AutoSkeletonView.mm +++ b/ios/React/AutoSkeletonView.mm @@ -102,6 +102,12 @@ - (void)layoutSubviews { [_view subviewsUpdated]; } +- (void)unmountChildComponentView:(UIView*)childComponentView + index:(NSInteger)index { + [_view restoreOriginalView:childComponentView]; + [super unmountChildComponentView:childComponentView index:index]; +} + Class AutoSkeletonViewCls(void) { return AutoSkeletonView.class; } diff --git a/ios/React/AutoSkeletonViewManager.mm b/ios/React/AutoSkeletonViewManager.mm index b10cf97..a9a2783 100644 --- a/ios/React/AutoSkeletonViewManager.mm +++ b/ios/React/AutoSkeletonViewManager.mm @@ -41,8 +41,9 @@ @implementation AutoSkeletonViewManager view.shapesBackgroundColor = uiColor; } +// `UIColorArray` makes the Old Arch view config run processColorArray on the JS side. RCT_CUSTOM_VIEW_PROPERTY(gradientColors, - NSArray, + UIColorArray, SkeletonViewOldArch) { NSArray* colors = [RCTConvert UIColorArray:json]; diff --git a/ios/SkeletonView/Animations/AnimationBase.swift b/ios/SkeletonView/Animations/AnimationBase.swift index bda8a3f..8ca246f 100644 --- a/ios/SkeletonView/Animations/AnimationBase.swift +++ b/ios/SkeletonView/Animations/AnimationBase.swift @@ -6,6 +6,7 @@ // import Foundation +import UIKit class AnimationBase:SkeletonAnimatable { diff --git a/ios/SkeletonView/Animations/AnimationGradient.swift b/ios/SkeletonView/Animations/AnimationGradient.swift index bb97906..2578128 100644 --- a/ios/SkeletonView/Animations/AnimationGradient.swift +++ b/ios/SkeletonView/Animations/AnimationGradient.swift @@ -6,6 +6,7 @@ // import Foundation +import UIKit private let ANIMATION_NAME = "alphaGradientAnimation" @@ -41,7 +42,9 @@ final class AnimationGradient: AnimationBase { gradientLayer.frame = delegate?.mainLayer.bounds ?? .zero - let colors = delegate?.gradientColors ?? DEFAULT_GRADIENT_COLORS + // A color that fails native conversion is dropped, so the array can arrive short. + let delegateColors = delegate?.gradientColors ?? [] + let colors = delegateColors.count >= 2 ? delegateColors : DEFAULT_GRADIENT_COLORS animation.fromValue = [ colors[0].cgColor, diff --git a/ios/SkeletonView/Animations/AnimationNone.swift b/ios/SkeletonView/Animations/AnimationNone.swift index 1551dc0..06a94d7 100644 --- a/ios/SkeletonView/Animations/AnimationNone.swift +++ b/ios/SkeletonView/Animations/AnimationNone.swift @@ -8,6 +8,7 @@ // import Foundation +import UIKit final class AnimationNone: AnimationBase { diff --git a/ios/SkeletonView/Animations/AnimationPulse.swift b/ios/SkeletonView/Animations/AnimationPulse.swift index b6231ae..2cc4c39 100644 --- a/ios/SkeletonView/Animations/AnimationPulse.swift +++ b/ios/SkeletonView/Animations/AnimationPulse.swift @@ -7,6 +7,7 @@ // import Foundation +import UIKit private let ANIMATION_NAME = "animationPulse" diff --git a/ios/SkeletonView/SkeletonCore.swift b/ios/SkeletonView/SkeletonCore.swift index b7c2ba8..7447420 100644 --- a/ios/SkeletonView/SkeletonCore.swift +++ b/ios/SkeletonView/SkeletonCore.swift @@ -183,6 +183,16 @@ public class SkeletonCore: UIView, PlaceholderMaskDelegate, SkeletonAnimatableDe hiddenViews.removeAllObjects() } + // Fabric recycles unmounted views without resetting `isHidden`, so restore before the view leaves. + public func restoreOriginalView(_ view: UIView) { + views.removeAll { $0 === view } + + guard let wasHidden = hiddenViews.object(forKey: view) else { return } + + view.isHidden = wasHidden.boolValue + hiddenViews.removeObject(forKey: view) + } + private func applyLoadingState() { guard appliedLoadingState != isLoading else { return } diff --git a/ios/SkeletonView/SkeletonPlaceholderMask.swift b/ios/SkeletonView/SkeletonPlaceholderMask.swift index 3d3fa03..bf649e0 100644 --- a/ios/SkeletonView/SkeletonPlaceholderMask.swift +++ b/ios/SkeletonView/SkeletonPlaceholderMask.swift @@ -6,6 +6,7 @@ // import Foundation +import UIKit final class SkeletonPlaceholderMask { weak var delegate: PlaceholderMaskDelegate? @@ -27,7 +28,8 @@ final class SkeletonPlaceholderMask { let combinedPath = UIBezierPath() for originalView in targetDelegate.views { - let convertedFrame = originalView.frame + // `frame` is in the parent's coordinates, which differ from ours for nested views. + let convertedFrame = originalView.convert(originalView.bounds, to: targetDelegate) let radius = originalView.layer.cornerRadius > 0 ? originalView.layer.cornerRadius : defaultBorderRadius diff --git a/ios/SkeletonView/SkeletonProtocols.swift b/ios/SkeletonView/SkeletonProtocols.swift index 160aa3c..ec7d13b 100644 --- a/ios/SkeletonView/SkeletonProtocols.swift +++ b/ios/SkeletonView/SkeletonProtocols.swift @@ -5,6 +5,8 @@ // Created by Oleksandr Shumihin on 30/3/25. // +import UIKit + protocol SkeletonRenderable: AnyObject { var mainLayer: CAShapeLayer { get set } } diff --git a/package.json b/package.json index d4805b4..3d40a00 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,8 @@ "scripts": { "example": "yarn workspace react-native-auto-skeleton-example", "test": "jest", + "test:ios": "cd ios-tests && xcodebuild test -scheme AutoSkeletonNativeTests-Package -destination \"${IOS_DESTINATION:-platform=iOS Simulator,name=iPhone 17 Pro Max}\"", + "test:ios:example": "cd example/ios && xcodebuild test -workspace AutoSkeletonExample.xcworkspace -scheme AutoSkeletonExample -configuration Debug -sdk iphonesimulator -destination \"${IOS_DESTINATION:-platform=iOS Simulator,name=iPhone 17 Pro Max}\" RCT_METRO_PORT=${RCT_METRO_PORT:-8081}", "typecheck": "tsc", "lint": "eslint \"**/*.{js,ts,tsx}\"", "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", @@ -90,6 +92,7 @@ "@release-it/conventional-changelog": "^9.0.2", "@types/jest": "^29.5.5", "@types/react": "^18.2.44", + "@types/react-test-renderer": "19.0.0", "commitlint": "^19.6.1", "del-cli": "^5.1.0", "eslint": "^8.51.0", @@ -100,6 +103,7 @@ "react": "19.0.0", "react-native": "0.78.1", "react-native-builder-bob": "^0.36.0", + "react-test-renderer": "19.0.0", "release-it": "^17.10.0", "turbo": "^1.10.7", "typescript": "^5.2.2" @@ -205,6 +209,12 @@ "name": "RNAutoSkeletonViewSpec", "type": "components", "jsSrcsDir": "src", + "ios": { + "componentProvider": { + "AutoSkeletonView": "AutoSkeletonView", + "AutoSkeletonIgnoreView": "AutoSkeletonIgnoreView" + } + }, "android": { "javaPackageName": "com.autoskeleton" } diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx new file mode 100644 index 0000000..41a4864 --- /dev/null +++ b/src/__tests__/index.test.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { Platform } from 'react-native'; +import TestRenderer, { act } from 'react-test-renderer'; +import { AutoSkeletonView } from '../index'; + +jest.mock('../AutoSkeletonViewNativeComponent', () => 'AutoSkeletonView'); + +const renderNativeProps = (element: React.ReactElement) => { + let renderer!: TestRenderer.ReactTestRenderer; + act(() => { + renderer = TestRenderer.create(element); + }); + return renderer.root.findByType('AutoSkeletonView' as never).props; +}; + +describe('AutoSkeletonView', () => { + const originalOS = Platform.OS; + + afterEach(() => { + Platform.OS = originalOS; + // @ts-ignore + delete global._IS_FABRIC; + }); + + it('applies the documented defaults', () => { + const props = renderNativeProps(); + + expect(props.isLoading).toBe(true); + expect(props.gradientColors).toEqual(['#D3D3D3', '#FFFFFF']); + expect(props.shimmerBackgroundColor).toBe('#CECECE'); + expect(props.defaultRadius).toBe(4); + expect(props.shimmerSpeed).toBe(1.0); + }); + + it('passes user values through unchanged', () => { + const props = renderNativeProps( + + ); + + expect(props.gradientColors).toEqual(['red', 'blue']); + expect(props.shimmerBackgroundColor).toBe('#000000'); + expect(props.defaultRadius).toBe(12); + expect(props.shimmerSpeed).toBe(2); + expect(props.animationType).toBe('pulse'); + }); + + // The native view config converts colors (processColorArray on Old Arch, codegen on Fabric); + // converting in JS as well would hand native already-processed numbers to convert again. + it.each([ + ['ios', undefined], + ['ios', false], + ['ios', true], + ['android', false], + ])( + 'never pre-processes gradientColors in JS (OS=%s, _IS_FABRIC=%s)', + (os, isFabric) => { + Platform.OS = os as typeof Platform.OS; + // @ts-ignore + global._IS_FABRIC = isFabric; + + const props = renderNativeProps( + + ); + + expect(props.gradientColors).toEqual(['#cccccc', '#F0F0F0']); + } + ); +}); diff --git a/src/__tests__/nativeContract.test.ts b/src/__tests__/nativeContract.test.ts new file mode 100644 index 0000000..d7218af --- /dev/null +++ b/src/__tests__/nativeContract.test.ts @@ -0,0 +1,103 @@ +import fs from 'fs'; +import path from 'path'; + +const root = path.resolve(__dirname, '../..'); +const read = (relative: string) => + fs.readFileSync(path.join(root, relative), 'utf8'); + +describe('Old Architecture view config', () => { + // The Old Arch JS view config is built from the type the manager exports for each prop. + const exportedPropType = (prop: string) => { + const manager = read('ios/React/AutoSkeletonViewManager.mm'); + const match = manager.match( + new RegExp(`RCT_CUSTOM_VIEW_PROPERTY\\(${prop},\\s*([\\w<>* ]+?),`) + ); + if (!match?.[1]) { + throw new Error(`${prop} is not exported by AutoSkeletonViewManager`); + } + return match[1].trim(); + }; + + const viewConfigFor = (nativeProps: Record) => { + jest.resetModules(); + jest.doMock('react-native/Libraries/ReactNative/UIManager', () => ({ + getViewManagerConfig: (name: string) => + name === 'AutoSkeletonView' ? { NativeProps: nativeProps } : null, + // iOS does not use lazy view managers, so there are no constants to merge. + getConstants: () => ({}), + })); + // Asset resolution pulls in Dimensions, whose Flow syntax this babel preset cannot parse. + jest.doMock('react-native/Libraries/Image/resolveAssetSource', () => + jest.fn() + ); + const getNativeComponentAttributes = require('react-native/Libraries/ReactNative/getNativeComponentAttributes'); + return getNativeComponentAttributes('AutoSkeletonView'); + }; + + // Without a processor, raw color strings reach RCTConvert and become an empty array (#20). + it('converts gradientColors to native colors before they reach the manager', () => { + const processColorArray = require('react-native/Libraries/StyleSheet/processColorArray'); + const colors = ['#D3D3D3', '#FFFFFF']; + + const attribute = viewConfigFor({ + gradientColors: exportedPropType('gradientColors'), + }).validAttributes.gradientColors; + + expect(typeof attribute.process).toBe('function'); + expect(attribute.process(colors)).toEqual(processColorArray(colors)); + expect(attribute.process(colors).every(Number.isInteger)).toBe(true); + }); + + it('converts shimmerBackgroundColor to a native color', () => { + const attribute = viewConfigFor({ + shimmerBackgroundColor: exportedPropType('shimmerBackgroundColor'), + }).validAttributes.shimmerBackgroundColor; + + expect(typeof attribute.process).toBe('function'); + expect(Number.isInteger(attribute.process('#CECECE'))).toBe(true); + }); +}); + +describe('Fabric component registration', () => { + const pkg = JSON.parse(read('package.json')); + const provider: Record = + pkg.codegenConfig?.ios?.componentProvider ?? {}; + + const specComponents = fs + .readdirSync(path.join(root, 'src')) + .filter((file) => file.endsWith('NativeComponent.ts')) + .map((file) => { + const match = read(`src/${file}`).match( + /codegenNativeComponent<\w+>\(\s*'(\w+)'/ + ); + return match?.[1]; + }) + .filter((name): name is string => Boolean(name)) + .sort(); + + const objcSources = fs + .readdirSync(path.join(root, 'ios/React')) + .filter((file) => file.endsWith('.mm')) + .map((file) => read(`ios/React/${file}`)) + .join('\n'); + + it('declares a component provider entry for every codegen component', () => { + expect(specComponents.length).toBeGreaterThan(0); + expect(Object.keys(provider).sort()).toEqual(specComponents); + }); + + it.each(specComponents)( + 'maps %s to a Fabric component view class', + (name) => { + const className = provider[name]; + + expect(className).toBeDefined(); + expect(objcSources).toMatch( + new RegExp(`@implementation ${className}\\b`) + ); + expect(objcSources).toMatch( + new RegExp(`Class ${name}Cls\\(void\\)`) + ); + } + ); +}); diff --git a/src/index.tsx b/src/index.tsx index 6bf3768..94a6284 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,8 +1,7 @@ // export { default as AutoSkeletonView } from './AutoSkeletonViewNativeComponent'; export { default as AutoSkeletonIgnoreView } from './AutoSkeletonIgnoreViewNativeComponent'; -import { ColorValue, Platform, processColor } from 'react-native'; +import type { ColorValue } from 'react-native'; import { default as AutoSkeletonView__ } from './AutoSkeletonViewNativeComponent'; -import { useMemo } from 'react'; import React from 'react'; interface IProps { @@ -63,21 +62,10 @@ const DEFAULT_ANIMATION_DURATION = 1.0; // seconds export const AutoSkeletonView: React.FC> = React.memo((props) => { - const gColors = useMemo(() => { - //@ts-ignore - if (Platform.OS === 'ios' && global._IS_FABRIC === false) { - return (props.gradientColors ?? DEFAULT_GRADIENT_COLORS).map((color) => - processColor(color) - ) as ColorValue[]; - } - - return props.gradientColors ?? DEFAULT_GRADIENT_COLORS; - }, [props.gradientColors]); - return ( =50.0.0" react: "*" react-native: "*" + peerDependenciesMeta: + "@expo/config-plugins": + optional: true + expo: + optional: true languageName: unknown linkType: soft @@ -10657,6 +10535,18 @@ __metadata: languageName: node linkType: hard +"react-test-renderer@npm:19.0.0": + version: 19.0.0 + resolution: "react-test-renderer@npm:19.0.0" + dependencies: + react-is: ^19.0.0 + scheduler: ^0.25.0 + peerDependencies: + react: ^19.0.0 + checksum: 2e1e527588c69e822b7aa25262c9f4a48161ede9cee5109b88228ecafbd91ce82f7afed176645efcba903ba5a43d05842a8229cdde220049e42a0cf679715dbc + languageName: node + linkType: hard + "react@npm:19.0.0": version: 19.0.0 resolution: "react@npm:19.0.0" @@ -11196,14 +11086,7 @@ __metadata: languageName: node linkType: hard -"sax@npm:>=0.6.0": - version: 1.4.1 - resolution: "sax@npm:1.4.1" - checksum: 3ad64df16b743f0f2eb7c38ced9692a6d924f1cd07bbe45c39576c2cf50de8290d9d04e7b2228f924c7d05fecc4ec5cf651423278e0c7b63d260c387ef3af84a - languageName: node - linkType: hard - -"scheduler@npm:0.25.0": +"scheduler@npm:0.25.0, scheduler@npm:^0.25.0": version: 0.25.0 resolution: "scheduler@npm:0.25.0" checksum: b7bb9fddbf743e521e9aaa5198a03ae823f5e104ebee0cb9ec625392bb7da0baa1c28ab29cee4b1e407a94e76acc6eee91eeb749614f91f853efda2613531566 @@ -11454,17 +11337,6 @@ __metadata: languageName: node linkType: hard -"simple-plist@npm:^1.1.0": - version: 1.4.0 - resolution: "simple-plist@npm:1.4.0" - dependencies: - bplist-creator: 0.1.1 - bplist-parser: 0.3.2 - plist: ^3.0.5 - checksum: fa8086f6b781c289f1abad21306481dda4af6373b32a5d998a70e53c2b7218a1d21ebb5ae3e736baae704c21d311d3d39d01d0e6a2387eda01b4020b9ebd909e - languageName: node - linkType: hard - "sisteransi@npm:^1.0.5": version: 1.0.5 resolution: "sisteransi@npm:1.0.5" @@ -11504,13 +11376,6 @@ __metadata: languageName: node linkType: hard -"slugify@npm:^1.6.6": - version: 1.6.6 - resolution: "slugify@npm:1.6.6" - checksum: 04773c2d3b7aea8d2a61fa47cc7e5d29ce04e1a96cbaec409da57139df906acb3a449fac30b167d203212c806e73690abd4ff94fbad0a9a7b7ea109a2a638ae9 - languageName: node - linkType: hard - "smart-buffer@npm:^4.2.0": version: 4.2.0 resolution: "smart-buffer@npm:4.2.0" @@ -11683,13 +11548,6 @@ __metadata: languageName: node linkType: hard -"stream-buffers@npm:2.2.x": - version: 2.2.0 - resolution: "stream-buffers@npm:2.2.0" - checksum: 4587d9e8f050d689fb38b4295e73408401b16de8edecc12026c6f4ae92956705ecfd995ae3845d7fa3ebf19502d5754df9143d91447fd881d86e518f43882c1c - languageName: node - linkType: hard - "string-length@npm:^4.0.1": version: 4.0.2 resolution: "string-length@npm:4.0.2" @@ -11919,15 +11777,6 @@ __metadata: languageName: node linkType: hard -"supports-color@npm:^5.3.0": - version: 5.5.0 - resolution: "supports-color@npm:5.5.0" - dependencies: - has-flag: ^3.0.0 - checksum: 95f6f4ba5afdf92f495b5a912d4abee8dcba766ae719b975c56c084f5004845f6f5a5f7769f52d53f40e21952a6d87411bafe34af4a01e65f9926002e38e1dac - languageName: node - linkType: hard - "supports-color@npm:^7.1.0": version: 7.2.0 resolution: "supports-color@npm:7.2.0" @@ -12515,15 +12364,6 @@ __metadata: languageName: node linkType: hard -"uuid@npm:^7.0.3": - version: 7.0.3 - resolution: "uuid@npm:7.0.3" - bin: - uuid: dist/bin/uuid - checksum: f5b7b5cc28accac68d5c083fd51cca64896639ebd4cca88c6cfb363801aaa83aa439c86dfc8446ea250a7a98d17afd2ad9e88d9d4958c79a412eccb93bae29de - languageName: node - linkType: hard - "v8-to-istanbul@npm:^9.0.1": version: 9.3.0 resolution: "v8-to-istanbul@npm:9.3.0" @@ -12815,16 +12655,6 @@ __metadata: languageName: node linkType: hard -"xcode@npm:^3.0.1": - version: 3.0.1 - resolution: "xcode@npm:3.0.1" - dependencies: - simple-plist: ^1.1.0 - uuid: ^7.0.3 - checksum: 908ff85851f81aec6e36ca24427db092e1cc068f052716e14de5e762196858039efabbe053a1abe8920184622501049e74a93618e8692b982f7604a9847db108 - languageName: node - linkType: hard - "xdg-basedir@npm:^5.1.0": version: 5.1.0 resolution: "xdg-basedir@npm:5.1.0" @@ -12832,30 +12662,6 @@ __metadata: languageName: node linkType: hard -"xml2js@npm:0.6.0": - version: 0.6.0 - resolution: "xml2js@npm:0.6.0" - dependencies: - sax: ">=0.6.0" - xmlbuilder: ~11.0.0 - checksum: 437f353fd66d367bf158e9555a0625df9965d944e499728a5c6bc92a54a2763179b144f14b7e1c725040f56bbd22b0fa6cfcb09ec4faf39c45ce01efe631f40b - languageName: node - linkType: hard - -"xmlbuilder@npm:^15.1.1": - version: 15.1.1 - resolution: "xmlbuilder@npm:15.1.1" - checksum: 14f7302402e28d1f32823583d121594a9dca36408d40320b33f598bd589ca5163a352d076489c9c64d2dc1da19a790926a07bf4191275330d4de2b0d85bb1843 - languageName: node - linkType: hard - -"xmlbuilder@npm:~11.0.0": - version: 11.0.1 - resolution: "xmlbuilder@npm:11.0.1" - checksum: 7152695e16f1a9976658215abab27e55d08b1b97bca901d58b048d2b6e106b5af31efccbdecf9b07af37c8377d8e7e821b494af10b3a68b0ff4ae60331b415b0 - languageName: node - linkType: hard - "xtend@npm:~4.0.1": version: 4.0.2 resolution: "xtend@npm:4.0.2"